mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-27 17:52:10 +03:00
Compare commits
342 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f3202f431 | |||
| 0cb6568fd5 | |||
| 6a90f98412 | |||
| 9318c2105f | |||
| e642f7324e | |||
| 7214ffafc5 | |||
| 88061bac10 | |||
| b5479f3f30 | |||
| d8aedcdde4 | |||
| 5f3e9ed0ea | |||
| 3e8a0eb93e | |||
| 4c2915586c | |||
| 9f06bffbea | |||
| e20d73ba7e | |||
| 8834e5fbbe | |||
| 6d732d8d32 | |||
| e4900f1bd4 | |||
| 04828246fc | |||
| c1efc48694 | |||
| f1760b0a28 | |||
| 745e394c74 | |||
| 737300b14b | |||
| 30469fcd10 | |||
| 887fca86ec | |||
| 6efc4b0665 | |||
| 94a7dbfe3c | |||
| e2649f98df | |||
| 3d839e0ee1 | |||
| a96612f595 | |||
| 8e7d215b4a | |||
| 24cd271486 | |||
| dee2525d5f | |||
| 81b4ae5661 | |||
| 9cbba130ab | |||
| cf5767acd1 | |||
| 444b05cac9 | |||
| f70e131dfe | |||
| 14165fc54d | |||
| 7cd26a0583 | |||
| 267fb1c866 | |||
| 5ac88271af | |||
| b776b33497 | |||
| 1478124712 | |||
| 9735d26b3d | |||
| 113a29733e | |||
| 3505430e57 | |||
| f68a14a3ca | |||
| 60e2af088d | |||
| 917f9b307e | |||
| 61c84e8223 | |||
| 72d8ebd269 | |||
| b885a1f8a6 | |||
| 439f4cf1e8 | |||
| bc00d37ad8 | |||
| 12c10dbd98 | |||
| 2fd2cd0af1 | |||
| 37fb48ffff | |||
| d8198f543b | |||
| f2bc4938b7 | |||
| 7f703f927e | |||
| f2c79b57fa | |||
| c394938f01 | |||
| b84b58ef21 | |||
| 10ebc6cbdc | |||
| a1b2382877 | |||
| 59c55dfc92 | |||
| 28a3dddb60 | |||
| 39bf31bd56 | |||
| 42b2ebc00b | |||
| 3b64a62137 | |||
| 79a7e7a5b5 | |||
| 3349dcbc13 | |||
| ad30298700 | |||
| 9be11e109e | |||
| 7117d19fd1 | |||
| c88627a839 | |||
| c718e7ca5b | |||
| 6a483fa987 | |||
| 47163c1418 | |||
| 09f4f09b84 | |||
| 3313086071 | |||
| 03d8ad4d5a | |||
| a8dff126c7 | |||
| 74e97fec4c | |||
| 50603fd430 | |||
| 8bea0fde2b | |||
| b2d32f588f | |||
| 8177f6dc66 | |||
| 77d94b25d0 | |||
| 32b7ada549 | |||
| 6099a07ff0 | |||
| e9806832ec | |||
| 15ebf3df10 | |||
| d44b70682c | |||
| fb75e3d7c7 | |||
| e9979b6774 | |||
| 2b83dc047b | |||
| c90f8a05bf | |||
| 9f96ef83ec | |||
| e19061d513 | |||
| 51e2fb6dbf | |||
| f21ed92296 | |||
| 22de983752 | |||
| 0b5c239f98 | |||
| 03393c9f52 | |||
| b56db67759 | |||
| 6d05702d00 | |||
| 9791b05a4e | |||
| 0aca2d3b3d | |||
| 8529f4f0cf | |||
| abc5cf3439 | |||
| a7e7788e29 | |||
| 8620344925 | |||
| 47e229e323 | |||
| 4521beab7c | |||
| a62c637632 | |||
| 35609b7b13 | |||
| a4b1b3d06d | |||
| 5f7c7c5f3d | |||
| 6bcaf61c44 | |||
| ff25072690 | |||
| 530c1597b8 | |||
| c8e16d8c41 | |||
| 17f67ef3a5 | |||
| eb4791a1cd | |||
| 71ac920436 | |||
| e6d0c33937 | |||
| eef2d311f4 | |||
| 772d2b6de4 | |||
| 8f30d14716 | |||
| 9611c9def6 | |||
| 292eb992f4 | |||
| 814e6ad69c | |||
| 0a38624ba7 | |||
| b86473df02 | |||
| 15be803da9 | |||
| c79b45e512 | |||
| 86a8eb16b4 | |||
| 0fd0389d5c | |||
| 2983ac3f8e | |||
| 975d6d1bad | |||
| ab7a7f7c6b | |||
| 733f44ef0f | |||
| faec3ca038 | |||
| df163854bd | |||
| 1af795fad8 | |||
| 085cb8c216 | |||
| 2a9ba2badc | |||
| 53fb4fe8f9 | |||
| 8d512d55e5 | |||
| a9d8905393 | |||
| ca2fd3814f | |||
| 394fafd29b | |||
| 9f0055d193 | |||
| 88dafa6cdf | |||
| 2b3b2770b4 | |||
| 094ea9faaa | |||
| eb16cca551 | |||
| aef0503f8f | |||
| 86304226a9 | |||
| 6d0e7ec495 | |||
| 04b4fb4384 | |||
| ae5ad505d0 | |||
| c188056f64 | |||
| 0a424a9f16 | |||
| 36b2a58675 | |||
| 59e9859225 | |||
| 4e5f144def | |||
| ea53da9341 | |||
| 3e1a102e9d | |||
| d580086361 | |||
| 1e3b366fba | |||
| c2a2a36f56 | |||
| e986a133f8 | |||
| 7466916e02 | |||
| 96b568b838 | |||
| fec714a243 | |||
| e02f78ac68 | |||
| 169b216d7e | |||
| 7e6d80efa5 | |||
| 38d87230d3 | |||
| f0f98c7122 | |||
| 554981d9d3 | |||
| a08f1c6c13 | |||
| 7f7ae0c547 | |||
| 60abeaad66 | |||
| a6d0100381 | |||
| 6767f76ccf | |||
| e4add73c9e | |||
| ff72090e1a | |||
| a3e1bd59df | |||
| 5bbb48a8fd | |||
| ee84d585f9 | |||
| 7b03346cfc | |||
| 258b08fff3 | |||
| a2097ad062 | |||
| 52fdf5d429 | |||
| 34d8885075 | |||
| 5740996436 | |||
| 874aae8080 | |||
| 842fae18d7 | |||
| ccd223aeea | |||
| 96b8fe472c | |||
| 59b695ba83 | |||
| 159b85f979 | |||
| 3ec5b3589f | |||
| 2b1d3e7347 | |||
| 37f0880f8f | |||
| 5b796672e9 | |||
| 3fa0da38c9 | |||
| 8eb1225734 | |||
| e5c0fe3edf | |||
| f4057989f5 | |||
| 84013b0b3f | |||
| 511adffc5b | |||
| fc6344b840 | |||
| b3555ce1b8 | |||
| c2f409c3c4 | |||
| 0994f8756f | |||
| 4779939424 | |||
| 4a455aa532 | |||
| 25f64738e4 | |||
| 5bb87fd3d4 | |||
| 491e3f9f8b | |||
| d8fb09faae | |||
| f87c68ea68 | |||
| 687e8cf1ba | |||
| 03f04194f2 | |||
| 248700a8a3 | |||
| ff128a7275 | |||
| e8d2973be7 | |||
| f3d47ebb3f | |||
| 06c49b92f8 | |||
| e35213bc73 | |||
| aa6a886977 | |||
| 9d603c5ad2 | |||
| a973fa6d68 | |||
| 3af6497577 | |||
| c59f54bb0e | |||
| 6b3da4fe5e | |||
| ea0da32e81 | |||
| d5ea8d0f38 | |||
| fd5f591737 | |||
| 8a4c9a98cb | |||
| 70b365171f | |||
| 328ba3b45e | |||
| 5370b6943a | |||
| d8c783a296 | |||
| 809f69729a | |||
| 93b7ce199f | |||
| 2a76cec804 | |||
| 88eab032be | |||
| 20ec863f51 | |||
| 2f4018bbe5 | |||
| f273708f6d | |||
| e6318d57e4 | |||
| 77fa976ee9 | |||
| 8098d2b1b1 | |||
| a691eaea8d | |||
| da447e5669 | |||
| f8c9aac97c | |||
| e42c17f2b2 | |||
| 427b7b67d8 | |||
| ccf08086ac | |||
| 7b0a3929ff | |||
| 570ab8e5e0 | |||
| 1240e4c962 | |||
| c117b8b272 | |||
| 6041d10e3d | |||
| 4800f8fb70 | |||
| a9770e1da2 | |||
| 3f15d21f13 | |||
| a6b3623634 | |||
| 947fd4fae1 | |||
| e69a31dd59 | |||
| 719ae0e014 | |||
| 5bcf6a8aeb | |||
| 945fefde12 | |||
| 313a2acbf6 | |||
| b747730211 | |||
| 692a73788a | |||
| 3287fa4d80 | |||
| 1393f981bc | |||
| 9a2c1c6b43 | |||
| 278aa1c85c | |||
| 8fe297ef9d | |||
| c881d1015a | |||
| c061337ce7 | |||
| 260eedf8c4 | |||
| 69ccdba734 | |||
| 4c797dc154 | |||
| f000322a06 | |||
| 0ea8b5352a | |||
| 68240061aa | |||
| 0695f677ba | |||
| 70f6d6b21a | |||
| e8c509c720 | |||
| 83a1c721c7 | |||
| 7ccc0877a1 | |||
| ad659e48cf | |||
| 784ed39930 | |||
| 538f7fd5d7 | |||
| cf38226b5d | |||
| 575ee854c8 | |||
| 9936af80dd | |||
| 4a75bd0a48 | |||
| b0c223c631 | |||
| 313b51f96f | |||
| 020cd63e22 | |||
| 6e46e9b16e | |||
| 713a7328f6 | |||
| 01d4a7488d | |||
| 2b2ed3349a | |||
| d8523bbdac | |||
| 8afa39144e | |||
| 00baeffe74 | |||
| b578a33518 | |||
| 8153e0ac05 | |||
| 2eb9d2e2e8 | |||
| a824875c4f | |||
| cafcb250ec | |||
| e7cfee570b | |||
| 90c3529301 | |||
| b65ec83c39 | |||
| 28a17a80ec | |||
| 3056583388 | |||
| 172f2ddaa7 | |||
| d69af328dc | |||
| ee0e3093ba | |||
| 89def9aee6 | |||
| b2b0024648 | |||
| 5822758b7c | |||
| 49430b3991 | |||
| 104526aab2 | |||
| a0c07241c0 | |||
| adf3242602 | |||
| 3f62592e4b | |||
| 02bff4db6c | |||
| 8ff4e1ff31 | |||
| 26c6438ec2 | |||
| b3e96230c4 | |||
| 1016f3b4f9 |
@@ -0,0 +1,4 @@
|
|||||||
|
XUI_DEBUG=true
|
||||||
|
XUI_DB_FOLDER=x-ui
|
||||||
|
XUI_LOG_FOLDER=x-ui
|
||||||
|
XUI_BIN_FOLDER=x-ui
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions" # See documentation for possible values
|
||||||
|
directory: "/" # Location of package manifests
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
name: Cleanup Caches
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *" # every day
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cleanup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
steps:
|
||||||
|
- name: Delete caches older than 1 day
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
CUTOFF_DATE=$(date -d "1 days ago" -Ins --utc | sed 's/+0000/Z/')
|
||||||
|
echo "Deleting caches older than: $CUTOFF_DATE"
|
||||||
|
|
||||||
|
CACHE_IDS=$(gh api --paginate repos/${{ github.repository }}/actions/caches \
|
||||||
|
--jq ".actions_caches[] | select(.last_accessed_at < \"$CUTOFF_DATE\") | .id" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -z "$CACHE_IDS" ]; then
|
||||||
|
echo "No old caches found to delete."
|
||||||
|
else
|
||||||
|
echo "$CACHE_IDS" | while read CACHE_ID; do
|
||||||
|
echo "Deleting cache: $CACHE_ID"
|
||||||
|
gh api -X DELETE repos/${{ github.repository }}/actions/caches/$CACHE_ID
|
||||||
|
done
|
||||||
|
echo "Old caches deleted successfully."
|
||||||
|
fi
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: "CodeQL Advanced"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags-ignore:
|
||||||
|
- "v*"
|
||||||
|
pull_request:
|
||||||
|
schedule:
|
||||||
|
- cron: "18 2 * * 2"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze (${{ matrix.language }})
|
||||||
|
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||||
|
env:
|
||||||
|
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: true
|
||||||
|
permissions:
|
||||||
|
security-events: write
|
||||||
|
packages: read
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- language: actions
|
||||||
|
build-mode: none
|
||||||
|
- language: go
|
||||||
|
build-mode: autobuild
|
||||||
|
- language: javascript-typescript
|
||||||
|
build-mode: none
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
# The Go binary embeds web/dist/ via //go:embed all:dist (web/web.go).
|
||||||
|
# web/dist/ is .gitignored, so CodeQL's autobuild for Go will fail with
|
||||||
|
# "pattern all:dist: no matching files found" unless vite emits it first.
|
||||||
|
- name: Setup Node.js
|
||||||
|
if: matrix.language == 'go'
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Build frontend bundle
|
||||||
|
if: matrix.language == 'go'
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v4
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
build-mode: ${{ matrix.build-mode }}
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v4
|
||||||
|
with:
|
||||||
|
category: "/language:${{matrix.language}}"
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
name: Release 3X-UI for Docker
|
name: Release 3X-UI for Docker
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
@@ -13,48 +15,46 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
submodules: true
|
submodules: true
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
hsanaeii/3x-ui
|
|
||||||
ghcr.io/mhsanaei/3x-ui
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=tag
|
|
||||||
type=pep440,pattern={{version}}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Docker meta
|
||||||
uses: docker/setup-qemu-action@v3
|
id: meta
|
||||||
|
uses: docker/metadata-action@v6
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
hsanaeii/3x-ui
|
||||||
|
ghcr.io/mhsanaei/3x-ui
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=tag
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up QEMU
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
with:
|
|
||||||
install: true
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Set up Docker Buildx
|
||||||
uses: docker/login-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
username: ${{ github.repository_owner }}
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Login to GHCR
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
registry: ghcr.io
|
||||||
push: true
|
username: ${{ github.actor }}
|
||||||
platforms: linux/amd64, linux/arm64/v8, linux/arm/v7, linux/arm/v6, linux/386
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6,linux/386
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
@@ -2,22 +2,23 @@ name: Release 3X-UI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- "**"
|
||||||
tags:
|
tags:
|
||||||
- "v*.*.*"
|
- "v*.*.*"
|
||||||
paths:
|
paths:
|
||||||
- '**.js'
|
- "**.js"
|
||||||
- '**.css'
|
- "**.css"
|
||||||
- '**.html'
|
- "**.html"
|
||||||
- '**.sh'
|
- "**.sh"
|
||||||
- '**.go'
|
- "**.go"
|
||||||
- 'go.mod'
|
- "go.mod"
|
||||||
- 'go.sum'
|
- "go.sum"
|
||||||
- 'x-ui.service'
|
- "x-ui.service.debian"
|
||||||
|
- "x-ui.service.arch"
|
||||||
|
- "x-ui.service.rhel"
|
||||||
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -36,7 +37,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
@@ -44,6 +45,23 @@ jobs:
|
|||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
|
# Frontend dist must be built BEFORE go build — Go's //go:embed
|
||||||
|
# all:dist directive in web/web.go requires web/dist/ to exist
|
||||||
|
# at compile time. web/dist/ is .gitignored, so on a fresh CI
|
||||||
|
# checkout it doesn't exist until vite emits it.
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Build frontend bundle
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Build 3X-UI
|
- name: Build 3X-UI
|
||||||
run: |
|
run: |
|
||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
@@ -75,17 +93,19 @@ jobs:
|
|||||||
go build -ldflags "-w -s -linkmode external -extldflags '-static'" -o xui-release -v main.go
|
go build -ldflags "-w -s -linkmode external -extldflags '-static'" -o xui-release -v main.go
|
||||||
file xui-release
|
file xui-release
|
||||||
ldd xui-release || echo "Static binary confirmed"
|
ldd xui-release || echo "Static binary confirmed"
|
||||||
|
|
||||||
mkdir x-ui
|
mkdir x-ui
|
||||||
cp xui-release x-ui/
|
cp xui-release x-ui/
|
||||||
cp x-ui.service x-ui/
|
cp x-ui.service.debian x-ui/
|
||||||
|
cp x-ui.service.arch x-ui/
|
||||||
|
cp x-ui.service.rhel x-ui/
|
||||||
cp x-ui.sh x-ui/
|
cp x-ui.sh x-ui/
|
||||||
mv x-ui/xui-release x-ui/x-ui
|
mv x-ui/xui-release x-ui/x-ui
|
||||||
mkdir x-ui/bin
|
mkdir x-ui/bin
|
||||||
cd x-ui/bin
|
cd x-ui/bin
|
||||||
|
|
||||||
# Download dependencies
|
# Download dependencies
|
||||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v25.9.11/"
|
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
|
||||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||||
wget -q ${Xray_URL}Xray-linux-64.zip
|
wget -q ${Xray_URL}Xray-linux-64.zip
|
||||||
unzip Xray-linux-64.zip
|
unzip Xray-linux-64.zip
|
||||||
@@ -124,24 +144,22 @@ jobs:
|
|||||||
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||||
mv xray xray-linux-${{ matrix.platform }}
|
mv xray xray-linux-${{ matrix.platform }}
|
||||||
cd ../..
|
cd ../..
|
||||||
|
|
||||||
- name: Package
|
- name: Package
|
||||||
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
|
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
|
||||||
|
|
||||||
- name: Upload files to Artifacts
|
- name: Upload files to Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: x-ui-linux-${{ matrix.platform }}
|
name: x-ui-linux-${{ matrix.platform }}
|
||||||
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
|
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||||
|
|
||||||
- name: Upload files to GH release
|
- name: Upload files to GH release
|
||||||
uses: svenstaro/upload-release-action@v2
|
uses: svenstaro/upload-release-action@v2
|
||||||
if: |
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
(github.event_name == 'release' && github.event.action == 'published') ||
|
|
||||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
with:
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
tag: ${{ github.ref }}
|
tag: ${{ github.ref_name }}
|
||||||
file: x-ui-linux-${{ matrix.platform }}.tar.gz
|
file: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||||
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
|
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||||
overwrite: true
|
overwrite: true
|
||||||
@@ -161,7 +179,7 @@ jobs:
|
|||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
@@ -169,21 +187,59 @@ jobs:
|
|||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Build 3X-UI for Windows
|
# Frontend dist must be built BEFORE go build — see comment on the
|
||||||
|
# Linux job above. This step is identical except npm runs on the
|
||||||
|
# Windows runner here.
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Build frontend bundle
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$env:CGO_ENABLED="1"
|
npm ci
|
||||||
$env:GOOS="windows"
|
npm run build
|
||||||
$env:GOARCH="amd64"
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Install MSYS2
|
||||||
|
uses: msys2/setup-msys2@v2
|
||||||
|
with:
|
||||||
|
msystem: MINGW64
|
||||||
|
update: true
|
||||||
|
install: >-
|
||||||
|
mingw-w64-x86_64-gcc
|
||||||
|
mingw-w64-x86_64-sqlite3
|
||||||
|
mingw-w64-x86_64-pkg-config
|
||||||
|
|
||||||
|
- name: Build 3X-UI for Windows (CGO)
|
||||||
|
shell: msys2 {0}
|
||||||
|
run: |
|
||||||
|
export PATH="/c/hostedtoolcache/windows/go/$(ls /c/hostedtoolcache/windows/go | sort -V | tail -n1)/x64/bin:$PATH"
|
||||||
|
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
export GOOS=windows
|
||||||
|
export GOARCH=amd64
|
||||||
|
export CC=x86_64-w64-mingw32-gcc
|
||||||
|
|
||||||
|
which go
|
||||||
|
go version
|
||||||
|
gcc --version
|
||||||
|
|
||||||
go build -ldflags "-w -s" -o xui-release.exe -v main.go
|
go build -ldflags "-w -s" -o xui-release.exe -v main.go
|
||||||
|
|
||||||
|
- name: Copy and download resources
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
mkdir x-ui
|
mkdir x-ui
|
||||||
Copy-Item xui-release.exe x-ui\
|
Copy-Item xui-release.exe x-ui\x-ui.exe
|
||||||
mkdir x-ui\bin
|
mkdir x-ui\bin
|
||||||
cd x-ui\bin
|
cd x-ui\bin
|
||||||
|
|
||||||
# Download Xray for Windows
|
# Download Xray for Windows
|
||||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v25.9.11/"
|
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
|
||||||
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||||
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||||
Remove-Item "Xray-windows-64.zip"
|
Remove-Item "Xray-windows-64.zip"
|
||||||
@@ -205,20 +261,18 @@ jobs:
|
|||||||
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
|
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
|
||||||
|
|
||||||
- name: Upload files to Artifacts
|
- name: Upload files to Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: x-ui-windows-amd64
|
name: x-ui-windows-amd64
|
||||||
path: ./x-ui-windows-amd64.zip
|
path: ./x-ui-windows-amd64.zip
|
||||||
|
|
||||||
- name: Upload files to GH release
|
- name: Upload files to GH release
|
||||||
uses: svenstaro/upload-release-action@v2
|
uses: svenstaro/upload-release-action@v2
|
||||||
if: |
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
(github.event_name == 'release' && github.event.action == 'published') ||
|
|
||||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
with:
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
tag: ${{ github.ref }}
|
tag: ${{ github.ref_name }}
|
||||||
file: x-ui-windows-amd64.zip
|
file: x-ui-windows-amd64.zip
|
||||||
asset_name: x-ui-windows-amd64.zip
|
asset_name: x-ui-windows-amd64.zip
|
||||||
overwrite: true
|
overwrite: true
|
||||||
prerelease: true
|
prerelease: true
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Ignore editor and IDE settings
|
# Ignore editor and IDE settings
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.claude/
|
||||||
.cache/
|
.cache/
|
||||||
.sync*
|
.sync*
|
||||||
|
|
||||||
@@ -32,6 +33,8 @@ Thumbs.db
|
|||||||
# Ignore Go build files
|
# Ignore Go build files
|
||||||
*.exe
|
*.exe
|
||||||
x-ui.db
|
x-ui.db
|
||||||
|
x-ui.db-shm
|
||||||
|
x-ui.db-wal
|
||||||
|
|
||||||
# Ignore Docker specific files
|
# Ignore Docker specific files
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|||||||
Vendored
+43
-8
@@ -5,36 +5,71 @@
|
|||||||
"label": "go: build",
|
"label": "go: build",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "go",
|
"command": "go",
|
||||||
"args": ["build", "-o", "bin/3x-ui.exe", "./main.go"],
|
"args": [
|
||||||
|
"build",
|
||||||
|
"-o",
|
||||||
|
"bin/3x-ui.exe",
|
||||||
|
"./main.go"
|
||||||
|
],
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
},
|
},
|
||||||
"problemMatcher": ["$go"],
|
"problemMatcher": [
|
||||||
"group": { "kind": "build", "isDefault": true }
|
"$go"
|
||||||
|
],
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "go: run",
|
"label": "go: run",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "go",
|
"command": "go",
|
||||||
"args": ["run", "./main.go"],
|
"args": [
|
||||||
|
"run",
|
||||||
|
"./main.go"
|
||||||
|
],
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"env": {
|
"env": {
|
||||||
"XUI_DEBUG": "true"
|
"XUI_DEBUG": "true"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"problemMatcher": ["$go"]
|
"problemMatcher": [
|
||||||
|
"$go"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "go: test",
|
"label": "go: test",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "go",
|
"command": "go",
|
||||||
"args": ["test", "./..."],
|
"args": [
|
||||||
|
"test",
|
||||||
|
"./..."
|
||||||
|
],
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
},
|
},
|
||||||
"problemMatcher": ["$go"],
|
"problemMatcher": [
|
||||||
|
"$go"
|
||||||
|
],
|
||||||
"group": "test"
|
"group": "test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "go: vet",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "go",
|
||||||
|
"args": [
|
||||||
|
"vet",
|
||||||
|
"./..."
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
"problemMatcher": [
|
||||||
|
"$go"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
## Local Development Setup
|
||||||
|
|
||||||
|
- Create a directory named `x-ui` in the project root
|
||||||
|
- Rename `.env.example` to `.env `
|
||||||
|
- Run `main.go`
|
||||||
+56
-2
@@ -1,7 +1,61 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
# Start fail2ban
|
# Start fail2ban with the 3x-ipl jail
|
||||||
[ $XUI_ENABLE_FAIL2BAN == "true" ] && fail2ban-client -x start
|
if [ "$XUI_ENABLE_FAIL2BAN" = "true" ]; then
|
||||||
|
LOG_FOLDER="${XUI_LOG_FOLDER:-/var/log/x-ui}"
|
||||||
|
mkdir -p "$LOG_FOLDER"
|
||||||
|
touch "$LOG_FOLDER/3xipl.log" "$LOG_FOLDER/3xipl-banned.log"
|
||||||
|
|
||||||
|
mkdir -p /etc/fail2ban/jail.d /etc/fail2ban/filter.d /etc/fail2ban/action.d
|
||||||
|
|
||||||
|
cat > /etc/fail2ban/jail.d/3x-ipl.conf << EOF
|
||||||
|
[3x-ipl]
|
||||||
|
enabled=true
|
||||||
|
backend=auto
|
||||||
|
filter=3x-ipl
|
||||||
|
action=3x-ipl
|
||||||
|
logpath=$LOG_FOLDER/3xipl.log
|
||||||
|
maxretry=1
|
||||||
|
findtime=32
|
||||||
|
bantime=30m
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
|
||||||
|
[Definition]
|
||||||
|
datepattern = ^%Y/%m/%d %H:%M:%S
|
||||||
|
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||||
|
ignoreregex =
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
|
||||||
|
[INCLUDES]
|
||||||
|
before = iptables-allports.conf
|
||||||
|
|
||||||
|
[Definition]
|
||||||
|
actionstart = <iptables> -N f2b-<name>
|
||||||
|
<iptables> -A f2b-<name> -j <returntype>
|
||||||
|
<iptables> -I <chain> -p <protocol> -j f2b-<name>
|
||||||
|
|
||||||
|
actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
||||||
|
<actionflush>
|
||||||
|
<iptables> -X f2b-<name>
|
||||||
|
|
||||||
|
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||||
|
|
||||||
|
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||||
|
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
|
||||||
|
|
||||||
|
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||||
|
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||||
|
|
||||||
|
[Init]
|
||||||
|
name = default
|
||||||
|
protocol = tcp
|
||||||
|
chain = INPUT
|
||||||
|
EOF
|
||||||
|
|
||||||
|
fail2ban-client -x start
|
||||||
|
fi
|
||||||
|
|
||||||
# Run x-ui
|
# Run x-ui
|
||||||
exec /app/x-ui
|
exec /app/x-ui
|
||||||
|
|||||||
+8
-8
@@ -27,14 +27,14 @@ case $1 in
|
|||||||
esac
|
esac
|
||||||
mkdir -p build/bin
|
mkdir -p build/bin
|
||||||
cd build/bin
|
cd build/bin
|
||||||
wget -q "https://github.com/XTLS/Xray-core/releases/download/v25.9.11/Xray-linux-${ARCH}.zip"
|
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/Xray-linux-${ARCH}.zip"
|
||||||
unzip "Xray-linux-${ARCH}.zip"
|
unzip "Xray-linux-${ARCH}.zip"
|
||||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||||
mv xray "xray-linux-${FNAME}"
|
mv xray "xray-linux-${FNAME}"
|
||||||
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||||
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||||
wget -q -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||||
wget -q -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
|
curl -sfLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
|
||||||
wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
curl -sfLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||||
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
curl -sfLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||||
cd ../../
|
cd ../../
|
||||||
|
|||||||
+18
-3
@@ -1,17 +1,29 @@
|
|||||||
|
# ========================================================
|
||||||
|
# Stage: Frontend (Vite)
|
||||||
|
# ========================================================
|
||||||
|
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
|
||||||
|
WORKDIR /src/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
COPY web/translation /src/web/translation
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
# ========================================================
|
# ========================================================
|
||||||
# Stage: Builder
|
# Stage: Builder
|
||||||
# ========================================================
|
# ========================================================
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
RUN apk --no-cache --update add \
|
RUN apk --no-cache --update add \
|
||||||
build-base \
|
build-base \
|
||||||
gcc \
|
gcc \
|
||||||
wget \
|
curl \
|
||||||
unzip
|
unzip
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
COPY --from=frontend /src/web/dist ./web/dist
|
||||||
|
|
||||||
ENV CGO_ENABLED=1
|
ENV CGO_ENABLED=1
|
||||||
ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
|
ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
|
||||||
@@ -29,11 +41,14 @@ RUN apk add --no-cache --update \
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
tzdata \
|
tzdata \
|
||||||
fail2ban \
|
fail2ban \
|
||||||
bash
|
bash \
|
||||||
|
curl \
|
||||||
|
openssl
|
||||||
|
|
||||||
COPY --from=builder /app/build/ /app/
|
COPY --from=builder /app/build/ /app/
|
||||||
COPY --from=builder /app/DockerEntrypoint.sh /app/
|
COPY --from=builder /app/DockerEntrypoint.sh /app/
|
||||||
COPY --from=builder /app/x-ui.sh /usr/bin/x-ui
|
COPY --from=builder /app/x-ui.sh /usr/bin/x-ui
|
||||||
|
COPY --from=builder /app/web/translation /app/web/translation
|
||||||
|
|
||||||
|
|
||||||
# Configure fail2ban
|
# Configure fail2ban
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<picture>
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
|
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
|
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||||
|
|
||||||
**3X-UI** — لوحة تحكم متقدمة مفتوحة المصدر تعتمد على الويب مصممة لإدارة خادم Xray-core. توفر واجهة سهلة الاستخدام لتكوين ومراقبة بروتوكولات VPN والوكيل المختلفة.
|
**3X-UI** — لوحة تحكم متقدمة مفتوحة المصدر تعتمد على الويب مصممة لإدارة خادم Xray-core. توفر واجهة سهلة الاستخدام لتكوين ومراقبة بروتوكولات VPN والوكيل المختلفة.
|
||||||
|
|
||||||
@@ -53,4 +53,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
|||||||
|
|
||||||
## النجوم عبر الزمن
|
## النجوم عبر الزمن
|
||||||
|
|
||||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<picture>
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
|
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
|
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||||
|
|
||||||
**3X-UI** — panel de control avanzado basado en web de código abierto diseñado para gestionar el servidor Xray-core. Ofrece una interfaz fácil de usar para configurar y monitorear varios protocolos VPN y proxy.
|
**3X-UI** — panel de control avanzado basado en web de código abierto diseñado para gestionar el servidor Xray-core. Ofrece una interfaz fácil de usar para configurar y monitorear varios protocolos VPN y proxy.
|
||||||
|
|
||||||
@@ -54,4 +54,4 @@ Para documentación completa, visita la [Wiki del proyecto](https://github.com/M
|
|||||||
|
|
||||||
## Estrellas a lo Largo del Tiempo
|
## Estrellas a lo Largo del Tiempo
|
||||||
|
|
||||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<picture>
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
|
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
|
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||||
|
|
||||||
**3X-UI** — یک پنل کنترل پیشرفته مبتنی بر وب با کد باز که برای مدیریت سرور Xray-core طراحی شده است. این پنل یک رابط کاربری آسان برای پیکربندی و نظارت بر پروتکلهای مختلف VPN و پراکسی ارائه میدهد.
|
**3X-UI** — یک پنل کنترل پیشرفته مبتنی بر وب با کد باز که برای مدیریت سرور Xray-core طراحی شده است. این پنل یک رابط کاربری آسان برای پیکربندی و نظارت بر پروتکلهای مختلف VPN و پراکسی ارائه میدهد.
|
||||||
|
|
||||||
@@ -54,4 +54,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
|||||||
|
|
||||||
## ستارهها در طول زمان
|
## ستارهها در طول زمان
|
||||||
|
|
||||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<picture>
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
|
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
|
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||||
|
|
||||||
**3X-UI** — advanced, open-source web-based control panel designed for managing Xray-core server. It offers a user-friendly interface for configuring and monitoring various VPN and proxy protocols.
|
**3X-UI** — advanced, open-source web-based control panel designed for managing Xray-core server. It offers a user-friendly interface for configuring and monitoring various VPN and proxy protocols.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> This project is only for personal using, please do not use it for illegal purposes, please do not use it in a production environment.
|
> This project is only for personal usage, please do not use it for illegal purposes, and please do not use it in a production environment.
|
||||||
|
|
||||||
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
|
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<picture>
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
|
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
|
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||||
|
|
||||||
**3X-UI** — продвинутая панель управления с открытым исходным кодом на основе веб-интерфейса, разработанная для управления сервером Xray-core. Предоставляет удобный интерфейс для настройки и мониторинга различных VPN и прокси-протоколов.
|
**3X-UI** — продвинутая панель управления с открытым исходным кодом на основе веб-интерфейса, разработанная для управления сервером Xray-core. Предоставляет удобный интерфейс для настройки и мониторинга различных VPN и прокси-протоколов.
|
||||||
|
|
||||||
@@ -54,4 +54,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
|||||||
|
|
||||||
## Звезды с течением времени
|
## Звезды с течением времени
|
||||||
|
|
||||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<picture>
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
|
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
|
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||||
|
|
||||||
**3X-UI** — 一个基于网页的高级开源控制面板,专为管理 Xray-core 服务器而设计。它提供了用户友好的界面,用于配置和监控各种 VPN 和代理协议。
|
**3X-UI** — 一个基于网页的高级开源控制面板,专为管理 Xray-core 服务器而设计。它提供了用户友好的界面,用于配置和监控各种 VPN 和代理协议。
|
||||||
|
|
||||||
@@ -54,4 +54,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
|||||||
|
|
||||||
## 随时间变化的星标数
|
## 随时间变化的星标数
|
||||||
|
|
||||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@ func GetLogFolder() string {
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return filepath.Join(".", "log")
|
return filepath.Join(".", "log")
|
||||||
}
|
}
|
||||||
return "/var/log"
|
return "/var/log/x-ui"
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyFile(src, dst string) error {
|
func copyFile(src, dst string) error {
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2.8.3
|
3.0.1
|
||||||
+67
-12
@@ -4,17 +4,18 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/config"
|
"github.com/mhsanaei/3x-ui/v3/config"
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
||||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -37,6 +38,8 @@ func initModels() error {
|
|||||||
&model.InboundClientIps{},
|
&model.InboundClientIps{},
|
||||||
&xray.ClientTraffic{},
|
&xray.ClientTraffic{},
|
||||||
&model.HistoryOfSeeders{},
|
&model.HistoryOfSeeders{},
|
||||||
|
&model.CustomGeoResource{},
|
||||||
|
&model.Node{},
|
||||||
}
|
}
|
||||||
for _, model := range models {
|
for _, model := range models {
|
||||||
if err := db.AutoMigrate(model); err != nil {
|
if err := db.AutoMigrate(model); err != nil {
|
||||||
@@ -86,11 +89,17 @@ func runSeeders(isUsersEmpty bool) error {
|
|||||||
return db.Create(hashSeeder).Error
|
return db.Create(hashSeeder).Error
|
||||||
} else {
|
} else {
|
||||||
var seedersHistory []string
|
var seedersHistory []string
|
||||||
db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory)
|
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
|
||||||
|
log.Printf("Error fetching seeder history: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
|
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
|
||||||
var users []model.User
|
var users []model.User
|
||||||
db.Find(&users)
|
if err := db.Find(&users).Error; err != nil {
|
||||||
|
log.Printf("Error fetching users for password migration: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
||||||
@@ -98,7 +107,10 @@ func runSeeders(isUsersEmpty bool) error {
|
|||||||
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
db.Model(&user).Update("password", hashedPassword)
|
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
|
||||||
|
log.Printf("Error updating password for user '%s': %v", user.Username, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hashSeeder := &model.HistoryOfSeeders{
|
hashSeeder := &model.HistoryOfSeeders{
|
||||||
@@ -121,7 +133,7 @@ func isTableEmpty(tableName string) (bool, error) {
|
|||||||
// InitDB sets up the database connection, migrates models, and runs seeders.
|
// InitDB sets up the database connection, migrates models, and runs seeders.
|
||||||
func InitDB(dbPath string) error {
|
func InitDB(dbPath string) error {
|
||||||
dir := path.Dir(dbPath)
|
dir := path.Dir(dbPath)
|
||||||
err := os.MkdirAll(dir, fs.ModePerm)
|
err := os.MkdirAll(dir, 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -137,11 +149,29 @@ func InitDB(dbPath string) error {
|
|||||||
c := &gorm.Config{
|
c := &gorm.Config{
|
||||||
Logger: gormLogger,
|
Logger: gormLogger,
|
||||||
}
|
}
|
||||||
db, err = gorm.Open(sqlite.Open(dbPath), c)
|
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
|
||||||
|
db, err = gorm.Open(sqlite.Open(dsn), c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(8)
|
||||||
|
sqlDB.SetMaxIdleConns(4)
|
||||||
|
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||||
|
|
||||||
if err := initModels(); err != nil {
|
if err := initModels(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -174,9 +204,8 @@ func GetDB() *gorm.DB {
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsNotFound checks if the given error is a GORM record not found error.
|
|
||||||
func IsNotFound(err error) bool {
|
func IsNotFound(err error) bool {
|
||||||
return err == gorm.ErrRecordNotFound
|
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
|
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
|
||||||
@@ -199,3 +228,29 @@ func Checkpoint() error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
|
||||||
|
// and runs a PRAGMA integrity_check to ensure the file is structurally sound.
|
||||||
|
// It does not mutate global state or run migrations.
|
||||||
|
func ValidateSQLiteDB(dbPath string) error {
|
||||||
|
if _, err := os.Stat(dbPath); err != nil { // file must exist
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sqlDB, err := gdb.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
var res string
|
||||||
|
if err := gdb.Raw("PRAGMA integrity_check;").Scan(&res).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if res != "ok" {
|
||||||
|
return errors.New("sqlite integrity check failed: " + res)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+90
-19
@@ -4,8 +4,8 @@ package model
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/json_util"
|
"github.com/mhsanaei/3x-ui/v3/util/json_util"
|
||||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Protocol represents the protocol type for Xray inbounds.
|
// Protocol represents the protocol type for Xray inbounds.
|
||||||
@@ -21,8 +21,21 @@ const (
|
|||||||
Shadowsocks Protocol = "shadowsocks"
|
Shadowsocks Protocol = "shadowsocks"
|
||||||
Mixed Protocol = "mixed"
|
Mixed Protocol = "mixed"
|
||||||
WireGuard Protocol = "wireguard"
|
WireGuard Protocol = "wireguard"
|
||||||
|
// UI stores Hysteria v1 and v2 both as "hysteria" and uses
|
||||||
|
// settings.version to discriminate. Imports from outside the panel
|
||||||
|
// can carry the literal "hysteria2" string, so IsHysteria below
|
||||||
|
// accepts both.
|
||||||
|
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.
|
// User represents a user account in the 3x-ui panel.
|
||||||
type User struct {
|
type User struct {
|
||||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
@@ -53,6 +66,12 @@ type Inbound struct {
|
|||||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||||
Sniffing string `json:"sniffing" form:"sniffing"`
|
Sniffing string `json:"sniffing" form:"sniffing"`
|
||||||
|
|
||||||
|
// NodeID points at the remote panel (Node) where this inbound's xray
|
||||||
|
// actually runs. NULL means the inbound runs on the local xray (the
|
||||||
|
// pre-multi-node behaviour). Existing rows migrate to NULL with no
|
||||||
|
// backfill.
|
||||||
|
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
||||||
@@ -80,9 +99,12 @@ type HistoryOfSeeders struct {
|
|||||||
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
|
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
|
||||||
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
||||||
listen := i.Listen
|
listen := i.Listen
|
||||||
if listen != "" {
|
// Default to 0.0.0.0 (all interfaces) when listen is empty
|
||||||
listen = fmt.Sprintf("\"%v\"", listen)
|
// This ensures proper dual-stack IPv4/IPv6 binding in systems where bindv6only=0
|
||||||
|
if listen == "" {
|
||||||
|
listen = "0.0.0.0"
|
||||||
}
|
}
|
||||||
|
listen = fmt.Sprintf("\"%v\"", listen)
|
||||||
return &xray.InboundConfig{
|
return &xray.InboundConfig{
|
||||||
Listen: json_util.RawMessage(listen),
|
Listen: json_util.RawMessage(listen),
|
||||||
Port: i.Port,
|
Port: i.Port,
|
||||||
@@ -101,21 +123,70 @@ type Setting struct {
|
|||||||
Value string `json:"value" form:"value"`
|
Value string `json:"value" form:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Node represents a remote 3x-ui panel registered with the central panel.
|
||||||
|
// The central panel polls each node's existing /panel/api/server/status
|
||||||
|
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
||||||
|
// status fields below.
|
||||||
|
type Node struct {
|
||||||
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
|
||||||
|
Remark string `json:"remark" form:"remark"`
|
||||||
|
Scheme string `json:"scheme" form:"scheme"`
|
||||||
|
Address string `json:"address" form:"address"`
|
||||||
|
Port int `json:"port" form:"port"`
|
||||||
|
BasePath string `json:"basePath" form:"basePath"`
|
||||||
|
ApiToken string `json:"apiToken" form:"apiToken"`
|
||||||
|
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
||||||
|
|
||||||
|
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
||||||
|
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
||||||
|
// truthful without us having to read LastHeartbeat separately.
|
||||||
|
Status string `json:"status" gorm:"default:unknown"` // online|offline|unknown
|
||||||
|
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
|
||||||
|
LatencyMs int `json:"latencyMs"`
|
||||||
|
XrayVersion string `json:"xrayVersion"`
|
||||||
|
CpuPct float64 `json:"cpuPct"`
|
||||||
|
MemPct float64 `json:"memPct"`
|
||||||
|
UptimeSecs uint64 `json:"uptimeSecs"`
|
||||||
|
LastError string `json:"lastError"`
|
||||||
|
|
||||||
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomGeoResource struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
Type string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
|
||||||
|
Alias string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
|
||||||
|
Url string `json:"url" gorm:"not null"`
|
||||||
|
LocalPath string `json:"localPath" gorm:"column:local_path"`
|
||||||
|
LastUpdatedAt int64 `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
|
||||||
|
LastModified string `json:"lastModified" gorm:"column:last_modified"`
|
||||||
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime;column:created_at"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientReverse struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
}
|
||||||
|
|
||||||
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ID string `json:"id"` // Unique client identifier
|
ID string `json:"id,omitempty"` // Unique client identifier
|
||||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||||
Password string `json:"password"` // Client password
|
Password string `json:"password,omitempty"` // Client password
|
||||||
Flow string `json:"flow"` // Flow control (XTLS)
|
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||||
Email string `json:"email"` // Client email identifier
|
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
||||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
Email string `json:"email"` // Client email identifier
|
||||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||||
Comment string `json:"comment" form:"comment"` // Client comment
|
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
Comment string `json:"comment" form:"comment"` // Client comment
|
||||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||||
|
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||||
|
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
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
-1
@@ -13,4 +13,4 @@ services:
|
|||||||
XUI_ENABLE_FAIL2BAN: "true"
|
XUI_ENABLE_FAIL2BAN: "true"
|
||||||
tty: true
|
tty: true
|
||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.vite/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 3x-ui frontend
|
||||||
|
|
||||||
|
Vue 3 + Ant Design Vue 4 + Vite. Multi-page app — one HTML entry per
|
||||||
|
panel route — built into `../web/dist/` and embedded into the Go binary
|
||||||
|
via `embed.FS`.
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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
|
||||||
|
production-style links work without round-tripping through Go.
|
||||||
|
|
||||||
|
## Production build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Lint
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
ESLint 10 with `eslint.config.js` (flat config) — `vue3-recommended`
|
||||||
|
plus a few rule overrides for the project's formatting style.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── *.html # Vite entry HTML, one per panel route
|
||||||
|
├── eslint.config.js
|
||||||
|
├── vite.config.js
|
||||||
|
└── src/
|
||||||
|
├── entries/ # Per-page bootstrap (createApp + mount)
|
||||||
|
├── pages/ # One folder per route, each with the page
|
||||||
|
│ ├── index/ # component + helpers + sub-components
|
||||||
|
│ ├── login/
|
||||||
|
│ ├── inbounds/
|
||||||
|
│ ├── xray/
|
||||||
|
│ ├── settings/
|
||||||
|
│ └── sub/
|
||||||
|
├── components/ # Cross-page Vue components
|
||||||
|
├── composables/ # Reusable reactive logic (useTheme, …)
|
||||||
|
├── api/ # Axios setup, CSRF interceptor
|
||||||
|
├── i18n/ # vue-i18n init (locales live in web/translation/)
|
||||||
|
├── models/ # Inbound, Outbound, Status, … domain classes
|
||||||
|
└── utils/ # HttpUtil, ObjectUtil, LanguageManager, …
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a new page
|
||||||
|
|
||||||
|
1. Add `frontend/<page>.html` referencing `/src/entries/<page>.js`.
|
||||||
|
2. Add `src/entries/<page>.js` that imports the page component and
|
||||||
|
mounts it.
|
||||||
|
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")`.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>3x-ui · API Docs</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="message"></div>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/entries/api-docs.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import vue from 'eslint-plugin-vue';
|
||||||
|
import vueParser from 'vue-eslint-parser';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ['node_modules/**', '../web/dist/**'] },
|
||||||
|
js.configs.recommended,
|
||||||
|
...vue.configs['flat/recommended'],
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,vue}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'module',
|
||||||
|
parser: vueParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: { jsx: false },
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
// Legacy script tags inject a couple of helpers on window before
|
||||||
|
// the SPA boots; declared here so no-undef stops flagging them.
|
||||||
|
getRandomRealityTarget: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['warn', {
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
}],
|
||||||
|
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||||
|
'no-case-declarations': 'off',
|
||||||
|
|
||||||
|
// Stylistic rules from vue/recommended that don't match the
|
||||||
|
// existing codebase formatting. Disable rather than churn the
|
||||||
|
// whole tree to satisfy them.
|
||||||
|
'vue/multi-word-component-names': 'off',
|
||||||
|
'vue/no-v-html': 'off',
|
||||||
|
'vue/html-self-closing': 'off',
|
||||||
|
'vue/max-attributes-per-line': 'off',
|
||||||
|
'vue/singleline-html-element-content-newline': 'off',
|
||||||
|
'vue/multiline-html-element-content-newline': 'off',
|
||||||
|
'vue/html-indent': 'off',
|
||||||
|
'vue/html-closing-bracket-newline': 'off',
|
||||||
|
'vue/attributes-order': 'off',
|
||||||
|
'vue/first-attribute-linebreak': 'off',
|
||||||
|
'vue/one-component-per-file': 'off',
|
||||||
|
'vue/order-in-components': 'off',
|
||||||
|
'vue/attribute-hyphenation': 'off',
|
||||||
|
'vue/v-on-event-hyphenation': 'off',
|
||||||
|
|
||||||
|
// Pervasive in form components ported from the Vue 2 codebase
|
||||||
|
// (parent passes a reactive object; child mutates it in place).
|
||||||
|
// Properly fixing this means rewiring those components to emit
|
||||||
|
// updates — a meaningful architectural change, separate task.
|
||||||
|
'vue/no-mutating-props': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>3x-ui · Inbounds</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="message"></div>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/entries/inbounds.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>3x-ui</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="message"></div>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/entries/index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="robots" content="noindex,nofollow" />
|
||||||
|
<title>3x-ui — Sign in</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="message"></div>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/entries/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>3x-ui · Nodes</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="message"></div>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/entries/nodes.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2742
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "3x-ui-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.2",
|
||||||
|
"type": "module",
|
||||||
|
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint src"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
|
"ant-design-vue": "^4.2.6",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
|
"otpauth": "^9.5.1",
|
||||||
|
"qs": "^6.13.1",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-i18n": "^11.1.4",
|
||||||
|
"vue3-persian-datetime-picker": "^1.2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-plugin-vue": "^10.9.1",
|
||||||
|
"globals": "^17.6.0",
|
||||||
|
"vite": "^8.0.11",
|
||||||
|
"vue-eslint-parser": "^10.4.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"moment-jalaali": "^0.10.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>3x-ui · Settings</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="message"></div>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/entries/settings.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import qs from 'qs';
|
||||||
|
|
||||||
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
||||||
|
// Public CSRF endpoint — works pre-login (the panel-scoped
|
||||||
|
// /panel/csrf-token sits behind checkLogin and would 401 a fresh
|
||||||
|
// login page that hasn't authenticated yet).
|
||||||
|
const CSRF_TOKEN_PATH = '/csrf-token';
|
||||||
|
|
||||||
|
// Cached session CSRF token. The legacy panel injects it via a
|
||||||
|
// <meta name="csrf-token"> tag rendered by Go; the new SPA pages
|
||||||
|
// fetch it once from /panel/csrf-token instead. Module-level so
|
||||||
|
// every axios POST sees the latest value.
|
||||||
|
let csrfToken = null;
|
||||||
|
let csrfFetchPromise = null;
|
||||||
|
|
||||||
|
function readMetaToken() {
|
||||||
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the token via a bare fetch() (not axios) so the call doesn't
|
||||||
|
// recurse through this same interceptor.
|
||||||
|
async function fetchCsrfToken() {
|
||||||
|
try {
|
||||||
|
const basePath = window.X_UI_BASE_PATH;
|
||||||
|
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
||||||
|
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
|
||||||
|
: CSRF_TOKEN_PATH);
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const json = await res.json();
|
||||||
|
return json?.success && typeof json.obj === 'string' ? json.obj : null;
|
||||||
|
} catch (_e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCsrfToken() {
|
||||||
|
if (csrfToken) return csrfToken;
|
||||||
|
const meta = readMetaToken();
|
||||||
|
if (meta) {
|
||||||
|
csrfToken = meta;
|
||||||
|
return csrfToken;
|
||||||
|
}
|
||||||
|
if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken();
|
||||||
|
const fetched = await csrfFetchPromise;
|
||||||
|
csrfFetchPromise = null;
|
||||||
|
if (fetched) csrfToken = fetched;
|
||||||
|
return csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the panel's axios defaults + interceptors. Call once at app
|
||||||
|
// startup before any HTTP call goes out.
|
||||||
|
export function setupAxios() {
|
||||||
|
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
||||||
|
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||||
|
|
||||||
|
const basePath = window.X_UI_BASE_PATH;
|
||||||
|
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
|
||||||
|
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 || {};
|
||||||
|
const method = (config.method || 'get').toUpperCase();
|
||||||
|
if (!SAFE_METHODS.has(method)) {
|
||||||
|
const token = await ensureCsrfToken();
|
||||||
|
if (token) config.headers['X-CSRF-Token'] = token;
|
||||||
|
}
|
||||||
|
if (config.data instanceof FormData) {
|
||||||
|
config.headers['Content-Type'] = 'multipart/form-data';
|
||||||
|
} else {
|
||||||
|
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error),
|
||||||
|
);
|
||||||
|
|
||||||
|
axios.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const status = error.response?.status;
|
||||||
|
if (status === 401) {
|
||||||
|
// 401 → session is gone. In production, the panel routes
|
||||||
|
// are gated by Go's checkLogin which redirects to base_path
|
||||||
|
// serving the login page; a reload is enough. In dev, Vite
|
||||||
|
// serves /index.html directly at "/", so a reload would put
|
||||||
|
// the user right back on the dashboard and the interceptor
|
||||||
|
// would loop. Navigate to the dev login entry instead.
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const basePath = window.X_UI_BASE_PATH || '/';
|
||||||
|
window.location.href = `${basePath}login.html`;
|
||||||
|
} else {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
|
||||||
|
const cfg = error.config;
|
||||||
|
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;
|
||||||
|
// axios re-stringifies on retry, so unwind our qs.stringify before
|
||||||
|
// letting the same request flow through the interceptor again.
|
||||||
|
if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data);
|
||||||
|
return axios(cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import {
|
||||||
|
DashboardOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
ToolOutlined,
|
||||||
|
ClusterOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
MenuOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
basePath: { type: String, default: '' },
|
||||||
|
// Current request URI so the matching menu item highlights.
|
||||||
|
requestUri: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const iconByName = {
|
||||||
|
dashboard: DashboardOutlined,
|
||||||
|
user: UserOutlined,
|
||||||
|
setting: SettingOutlined,
|
||||||
|
tool: ToolOutlined,
|
||||||
|
cluster: ClusterOutlined,
|
||||||
|
logout: LogoutOutlined,
|
||||||
|
apidocs: ApiOutlined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
|
||||||
|
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||||
|
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
||||||
|
{ 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: `${prefix}logout`, icon: 'logout', title: t('logout') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
|
||||||
|
const utilTabs = computed(() => tabs.value.filter((tab) => tab.icon === 'logout'));
|
||||||
|
const activeTab = ref([props.requestUri]);
|
||||||
|
const drawerOpen = ref(false);
|
||||||
|
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
|
||||||
|
const drawerWidth = 'min(82vw, 320px)';
|
||||||
|
|
||||||
|
function openLink(key) {
|
||||||
|
if (key.startsWith('http')) {
|
||||||
|
window.open(key);
|
||||||
|
} else {
|
||||||
|
window.location.href = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCollapse(isCollapsed, type) {
|
||||||
|
// Only persist explicit toggle clicks, not breakpoint-triggered collapses.
|
||||||
|
if (type === 'clickTrigger') {
|
||||||
|
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, isCollapsed);
|
||||||
|
collapsed.value = isCollapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDrawer() {
|
||||||
|
drawerOpen.value = !drawerOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
drawerOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleTheme() {
|
||||||
|
pauseAnimationsUntilLeave('theme-cycle');
|
||||||
|
if (!theme.isDark) {
|
||||||
|
toggleTheme();
|
||||||
|
if (theme.isUltra) toggleUltra();
|
||||||
|
} else if (!theme.isUltra) {
|
||||||
|
toggleUltra();
|
||||||
|
} else {
|
||||||
|
toggleUltra();
|
||||||
|
toggleTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ant-sidebar">
|
||||||
|
<a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
|
||||||
|
<div class="sider-brand" :class="{ 'sider-brand-collapsed': collapsed }">
|
||||||
|
<span class="brand-text">{{ collapsed ? '3X' : '3X-UI' }}</span>
|
||||||
|
<button v-if="!collapsed" id="theme-cycle" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
|
||||||
|
:title="t('menu.theme')" @click="cycleTheme">
|
||||||
|
<svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="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>
|
||||||
|
<svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="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 v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
|
||||||
|
stroke-linecap="round" stroke-linejoin="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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-nav"
|
||||||
|
@click="({ key }) => openLink(key)">
|
||||||
|
<a-menu-item v-for="tab in navTabs" :key="tab.key">
|
||||||
|
<component :is="iconByName[tab.icon]" />
|
||||||
|
<span>{{ tab.title }}</span>
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-utility"
|
||||||
|
@click="({ key }) => openLink(key)">
|
||||||
|
<a-menu-item v-for="tab in utilTabs" :key="tab.key">
|
||||||
|
<component :is="iconByName[tab.icon]" />
|
||||||
|
<span>{{ tab.title }}</span>
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</a-layout-sider>
|
||||||
|
|
||||||
|
<a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
|
||||||
|
:wrap-style="{ padding: 0 }" :width="drawerWidth"
|
||||||
|
:body-style="{ padding: 0, display: 'flex', flexDirection: 'column', height: '100%' }"
|
||||||
|
:header-style="{ display: 'none' }" @close="closeDrawer">
|
||||||
|
<div class="drawer-header">
|
||||||
|
<span class="drawer-brand">3X-UI</span>
|
||||||
|
<div class="drawer-header-actions">
|
||||||
|
<button id="theme-cycle-drawer" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
|
||||||
|
:title="t('menu.theme')" @click="cycleTheme">
|
||||||
|
<svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="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>
|
||||||
|
<svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="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 v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
|
||||||
|
stroke-linecap="round" stroke-linejoin="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>
|
||||||
|
</button>
|
||||||
|
<button class="drawer-close" type="button" :aria-label="t('close')" @click="closeDrawer">
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-nav"
|
||||||
|
@click="({ key }) => openLink(key)">
|
||||||
|
<a-menu-item v-for="tab in navTabs" :key="tab.key">
|
||||||
|
<component :is="iconByName[tab.icon]" />
|
||||||
|
<span>{{ tab.title }}</span>
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-utility"
|
||||||
|
@click="({ key }) => openLink(key)">
|
||||||
|
<a-menu-item v-for="tab in utilTabs" :key="tab.key">
|
||||||
|
<component :is="iconByName[tab.icon]" />
|
||||||
|
<span>{{ tab.title }}</span>
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</a-drawer>
|
||||||
|
|
||||||
|
<button v-show="!drawerOpen" class="drawer-handle" type="button" :aria-label="t('menu.dashboard')"
|
||||||
|
@click="toggleDrawer">
|
||||||
|
<MenuOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ant-sidebar>.ant-layout-sider {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-brand,
|
||||||
|
.drawer-brand {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 14px;
|
||||||
|
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed sider only has room for the '3X' brand — center it and
|
||||||
|
* hide the theme cycle button (which is `v-if`-ed out in template). */
|
||||||
|
.sider-brand-collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 14px 4px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-brand-collapsed .brand-text {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-cycle {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(0, 0, 0, 0.75);
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-cycle:hover,
|
||||||
|
.theme-cycle:focus-visible {
|
||||||
|
background-color: rgba(64, 150, 255, 0.1);
|
||||||
|
color: #4096ff;
|
||||||
|
transform: scale(1.08);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-cycle svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-handle {
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 1100;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-close:hover,
|
||||||
|
.drawer-close:focus-visible {
|
||||||
|
background: rgba(128, 128, 128, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-menu :deep(.ant-menu-item) {
|
||||||
|
height: 48px;
|
||||||
|
line-height: 48px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-menu :deep(.ant-menu-item .anticon) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-utility {
|
||||||
|
margin-top: auto;
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-brand {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-nav {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-utility {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.drawer-handle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
|
||||||
|
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-sidebar>.ant-layout-sider {
|
||||||
|
flex: 0 0 0 !important;
|
||||||
|
max-width: 0 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
width: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body.dark .drawer-brand,
|
||||||
|
body.dark .sider-brand {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .drawer-brand,
|
||||||
|
html[data-theme='ultra-dark'] .sider-brand {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 .theme-cycle {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .theme-cycle {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||||
|
.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||||
|
.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
value: { type: [String, Number], default: '' },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-statistic :title="title" :value="value">
|
||||||
|
<template #prefix>
|
||||||
|
<slot name="prefix" />
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<slot name="suffix" />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.ant-statistic-content) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark .ant-statistic-content) {
|
||||||
|
color: var(--dark-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark .ant-statistic-title) {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import PersianDatePicker from 'vue3-persian-datetime-picker';
|
||||||
|
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||||
|
|
||||||
|
// Drop-in replacement for <a-date-picker> that swaps to a real Jalali
|
||||||
|
// calendar (vue3-persian-datetime-picker, backed by moment-jalaali)
|
||||||
|
// when the panel's "Calendar Type" setting is `jalalian`.
|
||||||
|
//
|
||||||
|
// The v-model contract matches AD-Vue: the parent works with a dayjs
|
||||||
|
// object (or null). For the persian picker we serialize to/from the
|
||||||
|
// `YYYY-MM-DD HH:mm:ss` string it expects so callers don't need to
|
||||||
|
// know which renderer is active.
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: { type: [Object, null], default: null },
|
||||||
|
showTime: { type: Boolean, default: true },
|
||||||
|
format: { type: String, default: 'YYYY-MM-DD HH:mm:ss' },
|
||||||
|
placeholder: { type: String, default: '' },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:value']);
|
||||||
|
|
||||||
|
const { datepicker } = useDatepicker();
|
||||||
|
const isJalali = computed(() => datepicker.value === 'jalalian');
|
||||||
|
|
||||||
|
const ISO_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||||
|
|
||||||
|
// Persian picker's display format — `j…` tokens come from moment-jalaali
|
||||||
|
// and render Jalali year/month/day.
|
||||||
|
const persianDisplayFormat = computed(() =>
|
||||||
|
props.showTime ? 'jYYYY/jMM/jDD HH:mm:ss' : 'jYYYY/jMM/jDD',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persian picker stores the date as a Gregorian string in the format
|
||||||
|
// it was given via `format`. We normalize on `YYYY-MM-DD HH:mm:ss` so
|
||||||
|
// dayjs(...) round-trips cleanly.
|
||||||
|
const stringValue = computed({
|
||||||
|
get() {
|
||||||
|
const v = props.value;
|
||||||
|
if (!v) return '';
|
||||||
|
return dayjs.isDayjs(v) ? v.format(ISO_FORMAT) : dayjs(v).format(ISO_FORMAT);
|
||||||
|
},
|
||||||
|
set(next) {
|
||||||
|
if (!next) {
|
||||||
|
emit('update:value', null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = dayjs(next, ISO_FORMAT);
|
||||||
|
emit('update:value', parsed.isValid() ? parsed : null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onAntChange(next) {
|
||||||
|
emit('update:value', next || null);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PersianDatePicker v-if="isJalali" v-model="stringValue" :format="ISO_FORMAT" :display-format="persianDisplayFormat"
|
||||||
|
:placeholder="placeholder" :disabled="disabled" color="#1677ff" auto-submit append-to="body"
|
||||||
|
input-class="ant-input persian-datepicker-input" class="jalali-datepicker" />
|
||||||
|
<a-date-picker v-else :value="value" :show-time="showTime ? { format: 'HH:mm:ss' } : false" :format="format"
|
||||||
|
:placeholder="placeholder" :disabled="disabled" :style="{ width: '100%' }" @update:value="onAntChange" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jalali-datepicker {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Theme overrides for the picker. AD-Vue 4 doesn't expose CSS variables
|
||||||
|
by default (its tokens live in JS), so we hardcode hexes per theme
|
||||||
|
class — `body.dark` for the navy theme, `[data-theme="ultra-dark"]`
|
||||||
|
for the neutral ultra-dark variant. The popup stays inside the
|
||||||
|
wrapper's subtree (no teleport) so global selectors reach it cleanly. -->
|
||||||
|
<style>
|
||||||
|
/* ===== Light (default) =================================================== */
|
||||||
|
|
||||||
|
.persian-datepicker-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4px 11px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persian-datepicker-input:hover {
|
||||||
|
border-color: #4096ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persian-datepicker-input:focus {
|
||||||
|
border-color: #1677ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme keeps the picker's brand-blue calendar button (set via
|
||||||
|
* inline style on .vpd-icon-btn) — only its border + corner radius are
|
||||||
|
* normalized so it sits flush with the input. Dark/ultra-dark themes
|
||||||
|
* below override the inline blue so the control matches the form. */
|
||||||
|
.vpd-main .vpd-icon-btn {
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Match the input's left edge (no rounded left, no double border at the
|
||||||
|
* seam) so it sits flush against the icon-btn. */
|
||||||
|
.persian-datepicker-input {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-main .vpd-clear-btn {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Width is exactly 316px so the 7-day grid (7 × 40px + 36px padding)
|
||||||
|
* fits flush. Don't add `border` here — box-sizing: border-box would
|
||||||
|
* eat 2px from the content width and the 7th day-cell of each row
|
||||||
|
* wraps. Use box-shadow + a wider radius for the visual edge instead. */
|
||||||
|
.vpd-wrapper .vpd-content {
|
||||||
|
background: #fff;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||||
|
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||||
|
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-header {
|
||||||
|
background: #1677ff;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-header .vpd-year-label,
|
||||||
|
.vpd-wrapper .vpd-header .vpd-date,
|
||||||
|
.vpd-wrapper .vpd-header .vpd-locales li {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-body {
|
||||||
|
background: #fff;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-body .vpd-month-label,
|
||||||
|
.vpd-wrapper .vpd-body .vpd-month-label>span {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-body .vpd-week,
|
||||||
|
.vpd-wrapper .vpd-body .vpd-weekday {
|
||||||
|
color: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-body .vpd-controls .vpd-next,
|
||||||
|
.vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The picker's <arrow> component renders an inline SVG with a hardcoded
|
||||||
|
* `fill="#000"` attribute. Override the path fill via CSS so the arrow
|
||||||
|
* is visible in every theme. */
|
||||||
|
.vpd-wrapper .vpd-next svg path,
|
||||||
|
.vpd-wrapper .vpd-prev svg path {
|
||||||
|
fill: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
|
||||||
|
.vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
|
||||||
|
fill: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The picker paints disabled days as `darken(#fff, 20%)` (~#cccccc) which
|
||||||
|
* is invisible on white and dark themes alike. Reset the day text color
|
||||||
|
* across all states so days are always readable. */
|
||||||
|
.vpd-wrapper .vpd-day,
|
||||||
|
.vpd-wrapper .vpd-day .vpd-day-text {
|
||||||
|
color: rgba(0, 0, 0, 0.88) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-day[disabled='true'],
|
||||||
|
.vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
|
||||||
|
color: rgba(0, 0, 0, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-day:not([disabled='true']):hover .vpd-day-text,
|
||||||
|
.vpd-wrapper .vpd-day.vpd-selected .vpd-day-text {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-actions button {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-actions button:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-addon-list,
|
||||||
|
.vpd-wrapper .vpd-addon-list-content {
|
||||||
|
background: #fff;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-addon-list-item {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-addon-list-item.vpd-selected,
|
||||||
|
.vpd-wrapper .vpd-addon-list-item:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-wrapper .vpd-close-addon {
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Dark (navy) ======================================================= */
|
||||||
|
|
||||||
|
body.dark .persian-datepicker-input {
|
||||||
|
background: #252526;
|
||||||
|
border-color: #3c3c3c;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .persian-datepicker-input:hover {
|
||||||
|
border-color: #4096ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .persian-datepicker-input:focus {
|
||||||
|
border-color: #1677ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-main .vpd-icon-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.04) !important;
|
||||||
|
border: 1px solid #3c3c3c !important;
|
||||||
|
border-right: none !important;
|
||||||
|
border-radius: 6px 0 0 6px !important;
|
||||||
|
color: rgba(255, 255, 255, 0.75) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-content {
|
||||||
|
background: #2d2d30;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
|
||||||
|
0 3px 6px -4px rgba(0, 0, 0, 0.48),
|
||||||
|
0 9px 28px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-body {
|
||||||
|
background: #2d2d30;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-body .vpd-month-label,
|
||||||
|
body.dark .vpd-wrapper .vpd-body .vpd-month-label>span {
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-body .vpd-week,
|
||||||
|
body.dark .vpd-wrapper .vpd-body .vpd-weekday {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next,
|
||||||
|
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-next svg path,
|
||||||
|
body.dark .vpd-wrapper .vpd-prev svg path {
|
||||||
|
fill: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
|
||||||
|
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
|
||||||
|
fill: #4096ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-day,
|
||||||
|
body.dark .vpd-wrapper .vpd-day .vpd-day-text {
|
||||||
|
color: rgba(255, 255, 255, 0.88) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-day[disabled='true'],
|
||||||
|
body.dark .vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
|
||||||
|
color: rgba(255, 255, 255, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-actions button {
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-actions button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-addon-list,
|
||||||
|
body.dark .vpd-wrapper .vpd-addon-list-content {
|
||||||
|
background: #2d2d30;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-addon-list-item {
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-addon-list-item.vpd-selected,
|
||||||
|
body.dark .vpd-wrapper .vpd-addon-list-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .vpd-wrapper .vpd-close-addon {
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Ultra-dark (neutral black) ======================================= */
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .persian-datepicker-input {
|
||||||
|
background: #0a0a0a;
|
||||||
|
border-color: #303030;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .vpd-main .vpd-icon-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.04) !important;
|
||||||
|
border: 1px solid #303030 !important;
|
||||||
|
border-right: none !important;
|
||||||
|
border-radius: 6px 0 0 6px !important;
|
||||||
|
color: rgba(255, 255, 255, 0.75) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-content {
|
||||||
|
background: #141414;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-body {
|
||||||
|
background: #141414;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list,
|
||||||
|
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list-content {
|
||||||
|
background: #141414;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { RandomUtil } from '@/utils';
|
||||||
|
import { Protocols } from '@/models/inbound.js';
|
||||||
|
|
||||||
|
// Mirrors web/html/form/stream/stream_finalmask.html. Used by both the
|
||||||
|
// inbound and outbound modals — they share the same StreamSettings
|
||||||
|
// shape (`stream.finalmask`, `stream.addTcpMask()`, etc.) so a single
|
||||||
|
// component handles both. The host modal passes its protocol through
|
||||||
|
// so we know whether to show only the Hysteria-specific UDP types.
|
||||||
|
const props = defineProps({
|
||||||
|
stream: { type: Object, required: true },
|
||||||
|
protocol: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const isHysteria = computed(() => props.protocol === Protocols.HYSTERIA);
|
||||||
|
const network = computed(() => props.stream?.network || '');
|
||||||
|
|
||||||
|
const showTcp = computed(() => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network.value));
|
||||||
|
const showUdp = computed(() => isHysteria.value || network.value === 'kcp');
|
||||||
|
const showQuic = computed(() => isHysteria.value || network.value === 'xhttp');
|
||||||
|
|
||||||
|
// Reset the per-row settings shape when the user picks a different
|
||||||
|
// type — mirrors the legacy `mask._getDefaultSettings(type, {})` call.
|
||||||
|
function changeMaskType(mask, type) {
|
||||||
|
mask.type = type;
|
||||||
|
mask.settings = mask._getDefaultSettings(type, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special case from the legacy form: switching a UDP mask to xdns
|
||||||
|
// shrinks the kcp MTU; everything else needs the default 1350.
|
||||||
|
function changeUdpMaskType(mask, type) {
|
||||||
|
changeMaskType(mask, type);
|
||||||
|
if (network.value === 'kcp' && props.stream.kcp) {
|
||||||
|
props.stream.kcp.mtu = type === 'xdns' ? 900 : 1350;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// header-custom and noise rows share the same per-item shape — the
|
||||||
|
// type select rewires the packet field. Pulled out so the click
|
||||||
|
// handlers in the template stay readable.
|
||||||
|
function changeItemType(item, type) {
|
||||||
|
item.type = type;
|
||||||
|
if (type === 'base64') item.packet = RandomUtil.randomBase64();
|
||||||
|
else if (type === 'array') { item.rand = 0; item.packet = []; }
|
||||||
|
else item.packet = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUdpMaskWithDefault() {
|
||||||
|
const def = isHysteria.value ? 'salamander' : 'mkcp-aes128gcm';
|
||||||
|
props.stream.addUdpMask(def);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newClientServerItem() {
|
||||||
|
return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function newUdpClientServerItem() {
|
||||||
|
return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function newNoiseItem() {
|
||||||
|
return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-form v-if="showTcp || showUdp || showQuic" :colon="false" :label-col="{ md: { span: 8 } }"
|
||||||
|
:wrapper-col="{ md: { span: 14 } }">
|
||||||
|
<!-- ============================== TCP MASKS ============================== -->
|
||||||
|
<template v-if="showTcp">
|
||||||
|
<a-form-item label="TCP Masks">
|
||||||
|
<a-button type="primary" size="small" @click="stream.addTcpMask('fragment')">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<template v-for="(mask, mIdx) in (stream.finalmask.tcp || [])" :key="`tcp-${mIdx}`">
|
||||||
|
<a-divider :style="{ margin: '0' }">
|
||||||
|
TCP Mask {{ mIdx + 1 }}
|
||||||
|
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||||
|
@click="stream.delTcpMask(mIdx)" />
|
||||||
|
</a-divider>
|
||||||
|
|
||||||
|
<a-form-item label="Type">
|
||||||
|
<a-select :value="mask.type" @change="(t) => changeMaskType(mask, t)">
|
||||||
|
<a-select-option value="fragment">Fragment</a-select-option>
|
||||||
|
<a-select-option value="header-custom">Header Custom</a-select-option>
|
||||||
|
<a-select-option value="sudoku">Sudoku</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<!-- Fragment -->
|
||||||
|
<template v-if="mask.type === 'fragment'">
|
||||||
|
<a-form-item label="Packets">
|
||||||
|
<a-select v-model:value="mask.settings.packets">
|
||||||
|
<a-select-option value="tlshello">tlshello</a-select-option>
|
||||||
|
<a-select-option value="1-3">1-3</a-select-option>
|
||||||
|
<a-select-option value="1-5">1-5</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Length">
|
||||||
|
<a-input v-model:value="mask.settings.length" placeholder="e.g. 100-200" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Delay">
|
||||||
|
<a-input v-model:value="mask.settings.delay" placeholder="e.g. 10-20" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Max Split">
|
||||||
|
<a-input v-model:value="mask.settings.maxSplit" placeholder="e.g. 3-6" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Sudoku -->
|
||||||
|
<template v-if="mask.type === 'sudoku'">
|
||||||
|
<a-form-item label="Password">
|
||||||
|
<a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="ASCII">
|
||||||
|
<a-input v-model:value="mask.settings.ascii" placeholder="ASCII" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Custom Table">
|
||||||
|
<a-input v-model:value="mask.settings.customTable" placeholder="Custom Table" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Custom Tables">
|
||||||
|
<a-input v-model:value="mask.settings.customTables" placeholder="Custom Tables" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Padding Min">
|
||||||
|
<a-input-number v-model:value="mask.settings.paddingMin" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Padding Max">
|
||||||
|
<a-input-number v-model:value="mask.settings.paddingMax" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Header Custom — clients/servers as 2D groups -->
|
||||||
|
<template v-if="mask.type === 'header-custom'">
|
||||||
|
<!-- Clients -->
|
||||||
|
<a-form-item label="Clients">
|
||||||
|
<a-button type="primary" size="small" @click="mask.settings.clients.push([newClientServerItem()])">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-for="(group, gi) in mask.settings.clients" :key="`tcp-cg-${mIdx}-${gi}`">
|
||||||
|
<a-divider :style="{ margin: '0' }">
|
||||||
|
Clients Group {{ gi + 1 }}
|
||||||
|
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||||
|
@click="mask.settings.clients.splice(gi, 1)" />
|
||||||
|
</a-divider>
|
||||||
|
<template v-for="(item, ii) in group" :key="`tcp-ci-${mIdx}-${gi}-${ii}`">
|
||||||
|
<a-form-item label="Type">
|
||||||
|
<a-select :value="item.type" @change="(t) => changeItemType(item, t)">
|
||||||
|
<a-select-option value="array">Array</a-select-option>
|
||||||
|
<a-select-option value="str">String</a-select-option>
|
||||||
|
<a-select-option value="hex">Hex</a-select-option>
|
||||||
|
<a-select-option value="base64">Base64</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Delay (ms)">
|
||||||
|
<a-input-number v-model:value="item.delay" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="item.type === 'array'">
|
||||||
|
<a-form-item label="Rand">
|
||||||
|
<a-input-number v-model:value="item.rand" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Rand Range">
|
||||||
|
<a-input v-model:value="item.randRange" placeholder="0-255" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-form-item v-else label="Packet">
|
||||||
|
<a-input-group v-if="item.type === 'base64'" compact>
|
||||||
|
<a-input v-model:value="item.packet" placeholder="binary data"
|
||||||
|
:style="{ width: 'calc(100% - 32px)' }" />
|
||||||
|
<a-button @click="item.packet = RandomUtil.randomBase64()">
|
||||||
|
<template #icon>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Servers -->
|
||||||
|
<a-form-item label="Servers">
|
||||||
|
<a-button type="primary" size="small" @click="mask.settings.servers.push([newClientServerItem()])">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-for="(group, gi) in mask.settings.servers" :key="`tcp-sg-${mIdx}-${gi}`">
|
||||||
|
<a-divider :style="{ margin: '0' }">
|
||||||
|
Servers Group {{ gi + 1 }}
|
||||||
|
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||||
|
@click="mask.settings.servers.splice(gi, 1)" />
|
||||||
|
</a-divider>
|
||||||
|
<template v-for="(item, ii) in group" :key="`tcp-si-${mIdx}-${gi}-${ii}`">
|
||||||
|
<a-form-item label="Type">
|
||||||
|
<a-select :value="item.type" @change="(t) => changeItemType(item, t)">
|
||||||
|
<a-select-option value="array">Array</a-select-option>
|
||||||
|
<a-select-option value="str">String</a-select-option>
|
||||||
|
<a-select-option value="hex">Hex</a-select-option>
|
||||||
|
<a-select-option value="base64">Base64</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Delay (ms)">
|
||||||
|
<a-input-number v-model:value="item.delay" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="item.type === 'array'">
|
||||||
|
<a-form-item label="Rand">
|
||||||
|
<a-input-number v-model:value="item.rand" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Rand Range">
|
||||||
|
<a-input v-model:value="item.randRange" placeholder="0-255" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-form-item v-else label="Packet">
|
||||||
|
<a-input-group v-if="item.type === 'base64'" compact>
|
||||||
|
<a-input v-model:value="item.packet" placeholder="binary data"
|
||||||
|
:style="{ width: 'calc(100% - 32px)' }" />
|
||||||
|
<a-button @click="item.packet = RandomUtil.randomBase64()">
|
||||||
|
<template #icon>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============================== UDP MASKS ============================== -->
|
||||||
|
<template v-if="showUdp">
|
||||||
|
<a-form-item label="UDP Masks">
|
||||||
|
<a-button type="primary" size="small" @click="addUdpMaskWithDefault">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<template v-for="(mask, mIdx) in (stream.finalmask.udp || [])" :key="`udp-${mIdx}`">
|
||||||
|
<a-divider :style="{ margin: '0' }">
|
||||||
|
UDP Mask {{ mIdx + 1 }}
|
||||||
|
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||||
|
@click="stream.delUdpMask(mIdx)" />
|
||||||
|
</a-divider>
|
||||||
|
|
||||||
|
<a-form-item label="Type">
|
||||||
|
<a-select :value="mask.type" @change="(t) => changeUdpMaskType(mask, t)">
|
||||||
|
<template v-if="isHysteria">
|
||||||
|
<a-select-option value="salamander">Salamander (Hysteria2)</a-select-option>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a-select-option value="mkcp-aes128gcm">mKCP AES-128-GCM</a-select-option>
|
||||||
|
<a-select-option value="header-dns">Header DNS</a-select-option>
|
||||||
|
<a-select-option value="header-dtls">Header DTLS 1.2</a-select-option>
|
||||||
|
<a-select-option value="header-srtp">Header SRTP</a-select-option>
|
||||||
|
<a-select-option value="header-utp">Header uTP</a-select-option>
|
||||||
|
<a-select-option value="header-wechat">Header WeChat Video</a-select-option>
|
||||||
|
<a-select-option value="header-wireguard">Header WireGuard</a-select-option>
|
||||||
|
<a-select-option value="mkcp-original">mKCP Original</a-select-option>
|
||||||
|
<a-select-option value="xdns">xDNS</a-select-option>
|
||||||
|
<a-select-option value="xicmp">xICMP</a-select-option>
|
||||||
|
<a-select-option value="header-custom">Header Custom</a-select-option>
|
||||||
|
<a-select-option value="noise">Noise</a-select-option>
|
||||||
|
</template>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="['mkcp-aes128gcm', 'salamander'].includes(mask.type)" label="Password">
|
||||||
|
<a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="mask.type === 'header-dns'" label="Domain">
|
||||||
|
<a-input v-model:value="mask.settings.domain" placeholder="e.g., www.example.com" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="mask.type === 'xdns'" label="Domains">
|
||||||
|
<a-select v-model:value="mask.settings.domains" mode="tags" :style="{ width: '100%' }"
|
||||||
|
:token-separators="[',']" placeholder="e.g., www.example.com" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<!-- Noise -->
|
||||||
|
<template v-if="mask.type === 'noise'">
|
||||||
|
<a-form-item label="Reset">
|
||||||
|
<a-input-number v-model:value="mask.settings.reset" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Noise">
|
||||||
|
<a-button type="primary" size="small" @click="mask.settings.noise.push(newNoiseItem())">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-for="(n, ni) in mask.settings.noise" :key="`udp-noise-${mIdx}-${ni}`">
|
||||||
|
<a-divider :style="{ margin: '0' }">
|
||||||
|
Noise {{ ni + 1 }}
|
||||||
|
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||||
|
@click="mask.settings.noise.splice(ni, 1)" />
|
||||||
|
</a-divider>
|
||||||
|
<a-form-item label="Type">
|
||||||
|
<a-select :value="n.type" @change="(t) => changeItemType(n, t)">
|
||||||
|
<a-select-option value="array">Array</a-select-option>
|
||||||
|
<a-select-option value="str">String</a-select-option>
|
||||||
|
<a-select-option value="hex">Hex</a-select-option>
|
||||||
|
<a-select-option value="base64">Base64</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="n.type === 'array'">
|
||||||
|
<a-form-item label="Rand">
|
||||||
|
<a-input v-model:value="n.rand" placeholder="0 or 1-8192" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Rand Range">
|
||||||
|
<a-input v-model:value="n.randRange" placeholder="0-255" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-form-item v-else label="Packet">
|
||||||
|
<a-input-group v-if="n.type === 'base64'" compact>
|
||||||
|
<a-input v-model:value="n.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||||
|
<a-button @click="n.packet = RandomUtil.randomBase64()">
|
||||||
|
<template #icon>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
<a-input v-else v-model:value="n.packet" placeholder="binary data" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Delay">
|
||||||
|
<a-input v-model:value="n.delay" placeholder="10-20" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Header Custom (UDP) — flat client/server lists -->
|
||||||
|
<template v-if="mask.type === 'header-custom'">
|
||||||
|
<a-form-item label="Client">
|
||||||
|
<a-button type="primary" size="small" @click="mask.settings.client.push(newUdpClientServerItem())">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-for="(c, ci) in mask.settings.client" :key="`udp-c-${mIdx}-${ci}`">
|
||||||
|
<a-divider :style="{ margin: '0' }">
|
||||||
|
Client {{ ci + 1 }}
|
||||||
|
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||||
|
@click="mask.settings.client.splice(ci, 1)" />
|
||||||
|
</a-divider>
|
||||||
|
<a-form-item label="Type">
|
||||||
|
<a-select :value="c.type" @change="(t) => changeItemType(c, t)">
|
||||||
|
<a-select-option value="array">Array</a-select-option>
|
||||||
|
<a-select-option value="str">String</a-select-option>
|
||||||
|
<a-select-option value="hex">Hex</a-select-option>
|
||||||
|
<a-select-option value="base64">Base64</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="c.type === 'array'">
|
||||||
|
<a-form-item label="Rand">
|
||||||
|
<a-input-number v-model:value="c.rand" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Rand Range">
|
||||||
|
<a-input v-model:value="c.randRange" placeholder="0-255" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-form-item v-else label="Packet">
|
||||||
|
<a-input-group v-if="c.type === 'base64'" compact>
|
||||||
|
<a-input v-model:value="c.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||||
|
<a-button @click="c.packet = RandomUtil.randomBase64()">
|
||||||
|
<template #icon>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
<a-input v-else v-model:value="c.packet" placeholder="binary data" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-divider :style="{ margin: '0' }" />
|
||||||
|
<a-form-item label="Server">
|
||||||
|
<a-button type="primary" size="small" @click="mask.settings.server.push(newUdpClientServerItem())">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-for="(s, si) in mask.settings.server" :key="`udp-s-${mIdx}-${si}`">
|
||||||
|
<a-divider :style="{ margin: '0' }">
|
||||||
|
Server {{ si + 1 }}
|
||||||
|
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||||
|
@click="mask.settings.server.splice(si, 1)" />
|
||||||
|
</a-divider>
|
||||||
|
<a-form-item label="Type">
|
||||||
|
<a-select :value="s.type" @change="(t) => changeItemType(s, t)">
|
||||||
|
<a-select-option value="array">Array</a-select-option>
|
||||||
|
<a-select-option value="str">String</a-select-option>
|
||||||
|
<a-select-option value="hex">Hex</a-select-option>
|
||||||
|
<a-select-option value="base64">Base64</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="s.type === 'array'">
|
||||||
|
<a-form-item label="Rand">
|
||||||
|
<a-input-number v-model:value="s.rand" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Rand Range">
|
||||||
|
<a-input v-model:value="s.randRange" placeholder="0-255" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-form-item v-else label="Packet">
|
||||||
|
<a-input-group v-if="s.type === 'base64'" compact>
|
||||||
|
<a-input v-model:value="s.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||||
|
<a-button @click="s.packet = RandomUtil.randomBase64()">
|
||||||
|
<template #icon>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
<a-input v-else v-model:value="s.packet" placeholder="binary data" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- xICMP -->
|
||||||
|
<template v-if="mask.type === 'xicmp'">
|
||||||
|
<a-form-item label="IP">
|
||||||
|
<a-input v-model:value="mask.settings.ip" placeholder="0.0.0.0" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="ID">
|
||||||
|
<a-input-number v-model:value="mask.settings.id" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============================== QUIC PARAMS ============================== -->
|
||||||
|
<template v-if="showQuic">
|
||||||
|
<a-form-item label="QUIC Params">
|
||||||
|
<a-switch v-model:checked="stream.finalmask.enableQuicParams" />
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="stream.finalmask.enableQuicParams && stream.finalmask.quicParams">
|
||||||
|
<a-form-item label="Congestion">
|
||||||
|
<a-select v-model:value="stream.finalmask.quicParams.congestion">
|
||||||
|
<a-select-option value="reno">Reno</a-select-option>
|
||||||
|
<a-select-option value="bbr">BBR</a-select-option>
|
||||||
|
<a-select-option value="brutal">Brutal</a-select-option>
|
||||||
|
<a-select-option value="force-brutal">Force Brutal</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Debug">
|
||||||
|
<a-switch v-model:checked="stream.finalmask.quicParams.debug" />
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="['brutal', 'force-brutal'].includes(stream.finalmask.quicParams.congestion)">
|
||||||
|
<a-form-item label="Brutal Up">
|
||||||
|
<a-input v-model:value="stream.finalmask.quicParams.brutalUp" placeholder="65537" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Brutal Down">
|
||||||
|
<a-input v-model:value="stream.finalmask.quicParams.brutalDown" placeholder="65537" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-form-item label="UDP Hop">
|
||||||
|
<a-switch v-model:checked="stream.finalmask.quicParams.hasUdpHop" />
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="stream.finalmask.quicParams.hasUdpHop && stream.finalmask.quicParams.udpHop">
|
||||||
|
<a-form-item label="Hop Ports">
|
||||||
|
<a-input v-model:value="stream.finalmask.quicParams.udpHop.ports" placeholder="e.g. 20000-50000" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Hop Interval (s)">
|
||||||
|
<a-input-number v-model:value="stream.finalmask.quicParams.udpHop.interval" :min="5" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-form-item label="Max Idle Timeout (s)">
|
||||||
|
<a-input-number v-model:value="stream.finalmask.quicParams.maxIdleTimeout" :min="4" :max="120" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Keep Alive Period (s)">
|
||||||
|
<a-input-number v-model:value="stream.finalmask.quicParams.keepAlivePeriod" :min="2" :max="60" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Disable Path MTU Dis">
|
||||||
|
<a-switch v-model:checked="stream.finalmask.quicParams.disablePathMTUDiscovery" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Max Incoming Streams">
|
||||||
|
<a-input-number v-model:value="stream.finalmask.quicParams.maxIncomingStreams" :min="8"
|
||||||
|
placeholder="1024 = default" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Init Stream Window">
|
||||||
|
<a-input-number v-model:value="stream.finalmask.quicParams.initStreamReceiveWindow" :min="16384"
|
||||||
|
placeholder="8388608 = default" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Max Stream Window">
|
||||||
|
<a-input-number v-model:value="stream.finalmask.quicParams.maxStreamReceiveWindow" :min="16384"
|
||||||
|
placeholder="8388608 = default" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Init Conn Window">
|
||||||
|
<a-input-number v-model:value="stream.finalmask.quicParams.initConnectionReceiveWindow" :min="16384"
|
||||||
|
placeholder="20971520 = default" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Max Conn Window">
|
||||||
|
<a-input-number v-model:value="stream.finalmask.quicParams.maxConnectionReceiveWindow" :min="16384"
|
||||||
|
placeholder="20971520 = default" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-form>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup>
|
||||||
|
// Inline ∞ SVG. The Unicode infinity character (U+221E) renders as an
|
||||||
|
// "m"-shaped glyph in some system fonts (Windows Segoe UI in particular),
|
||||||
|
// so the inbound list and client row table use this SVG instead. The
|
||||||
|
// path matches what the legacy panel embedded.
|
||||||
|
defineProps({
|
||||||
|
width: { type: [String, Number], default: 14 },
|
||||||
|
height: { type: [String, Number], default: 10 },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg :width="width" :height="height" viewBox="0 0 640 512" fill="currentColor" aria-hidden="true"
|
||||||
|
style="vertical-align: -1px; display: inline-block;">
|
||||||
|
<path
|
||||||
|
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
// Generic prompt modal — used by features like "import inbound" that
|
||||||
|
// need a free-form text/textarea input and a confirm callback. The
|
||||||
|
// parent owns the action; this component only surfaces the value via
|
||||||
|
// the `confirm` event when the user clicks OK.
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
okText: { type: String, default: 'OK' },
|
||||||
|
// 'text' = single-line input; 'textarea' = multi-line.
|
||||||
|
type: { type: String, default: 'text', validator: (v) => ['text', 'textarea'].includes(v) },
|
||||||
|
initialValue: { type: String, default: '' },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'confirm']);
|
||||||
|
|
||||||
|
const value = ref('');
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (next) value.value = props.initialValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() { emit('update:open', false); }
|
||||||
|
function ok() { emit('confirm', value.value); }
|
||||||
|
|
||||||
|
// Enter submits when single-line; ctrl+S submits in textarea mode
|
||||||
|
// (matches legacy keybindings).
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (props.type !== 'textarea' && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="title" :ok-text="okText" cancel-text="Cancel" :mask-closable="false"
|
||||||
|
:confirm-loading="loading" @ok="ok" @cancel="close">
|
||||||
|
<a-textarea v-if="type === 'textarea'" v-model:value="value" :auto-size="{ minRows: 10, maxRows: 20 }" autofocus
|
||||||
|
@keydown="onKeydown" />
|
||||||
|
<a-input v-else v-model:value="value" autofocus @keydown="onKeydown" />
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
paddings: {
|
||||||
|
type: String,
|
||||||
|
default: 'default',
|
||||||
|
validator: (value) => ['small', 'default'].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const padding = computed(() =>
|
||||||
|
props.paddings === 'small' ? '10px 20px !important' : '20px !important',
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-list-item :style="{ padding }">
|
||||||
|
<a-row :gutter="[8, 16]">
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-list-item-meta>
|
||||||
|
<template #title>
|
||||||
|
<slot name="title" />
|
||||||
|
</template>
|
||||||
|
<template #description>
|
||||||
|
<slot name="description" />
|
||||||
|
</template>
|
||||||
|
</a-list-item-meta>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<slot name="control" />
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-list-item>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: { type: Array, required: true },
|
||||||
|
labels: { type: Array, default: () => [] },
|
||||||
|
vbWidth: { type: Number, default: 320 },
|
||||||
|
height: { type: Number, default: 80 },
|
||||||
|
stroke: { type: String, default: '#008771' },
|
||||||
|
strokeWidth: { type: Number, default: 2 },
|
||||||
|
maxPoints: { type: Number, default: 120 },
|
||||||
|
showGrid: { type: Boolean, default: true },
|
||||||
|
gridColor: { type: String, default: 'rgba(0,0,0,0.1)' },
|
||||||
|
fillOpacity: { type: Number, default: 0.15 },
|
||||||
|
showMarker: { type: Boolean, default: true },
|
||||||
|
markerRadius: { type: Number, default: 2.8 },
|
||||||
|
showAxes: { type: Boolean, default: false },
|
||||||
|
yTickStep: { type: Number, default: 25 },
|
||||||
|
tickCountX: { type: Number, default: 4 },
|
||||||
|
paddingLeft: { type: Number, default: 32 },
|
||||||
|
paddingRight: { type: Number, default: 6 },
|
||||||
|
paddingTop: { type: Number, default: 6 },
|
||||||
|
paddingBottom: { type: Number, default: 20 },
|
||||||
|
showTooltip: { type: Boolean, default: false },
|
||||||
|
// Value-range customization. When valueMax is null the chart auto-scales
|
||||||
|
// to the running max of the data (useful for unbounded series like
|
||||||
|
// network throughput or online clients). Defaults preserve the legacy
|
||||||
|
// 0..100 percent behavior so existing callers don't need to change.
|
||||||
|
valueMin: { type: Number, default: 0 },
|
||||||
|
valueMax: { type: [Number, null], default: 100 },
|
||||||
|
// Y-axis tick formatter. Receives the raw value, returns the label.
|
||||||
|
// tooltipFormatter formats the hover-readout; falls back to yFormatter.
|
||||||
|
yFormatter: { type: Function, default: (v) => `${Math.round(v)}%` },
|
||||||
|
tooltipFormatter: { type: Function, default: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const hoverIdx = ref(-1);
|
||||||
|
|
||||||
|
// Measured CSS width of the SVG. Drives the viewBox so SVG units stay
|
||||||
|
// 1:1 with rendered pixels — otherwise `preserveAspectRatio="none"`
|
||||||
|
// stretches the X axis and squashes axis text horizontally on narrow
|
||||||
|
// containers (mobile). Falls back to the prop until the first measure.
|
||||||
|
const svgRef = ref(null);
|
||||||
|
const measuredWidth = ref(0);
|
||||||
|
const effectiveVbWidth = computed(() => measuredWidth.value > 0 ? measuredWidth.value : props.vbWidth);
|
||||||
|
|
||||||
|
let resizeObserver = null;
|
||||||
|
function measure() {
|
||||||
|
const el = svgRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
const w = el.getBoundingClientRect?.().width || 0;
|
||||||
|
if (w > 0) measuredWidth.value = Math.round(w);
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
measure();
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && svgRef.value) {
|
||||||
|
resizeObserver = new ResizeObserver(measure);
|
||||||
|
resizeObserver.observe(svgRef.value);
|
||||||
|
} else {
|
||||||
|
window.addEventListener('resize', measure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (resizeObserver) resizeObserver.disconnect();
|
||||||
|
else window.removeEventListener('resize', measure);
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewBoxAttr = computed(() => `0 0 ${effectiveVbWidth.value} ${props.height}`);
|
||||||
|
const drawWidth = computed(() => Math.max(1, effectiveVbWidth.value - props.paddingLeft - props.paddingRight));
|
||||||
|
const drawHeight = computed(() => Math.max(1, props.height - props.paddingTop - props.paddingBottom));
|
||||||
|
const nPoints = computed(() => Math.min(props.data.length, props.maxPoints));
|
||||||
|
|
||||||
|
const dataSlice = computed(() => {
|
||||||
|
const n = nPoints.value;
|
||||||
|
if (n === 0) return [];
|
||||||
|
return props.data.slice(props.data.length - n);
|
||||||
|
});
|
||||||
|
|
||||||
|
const labelsSlice = computed(() => {
|
||||||
|
const n = nPoints.value;
|
||||||
|
if (!props.labels?.length || n === 0) return [];
|
||||||
|
const start = Math.max(0, props.labels.length - n);
|
||||||
|
return props.labels.slice(start);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolved domain. When valueMax is null we auto-scale; pad the upper
|
||||||
|
// bound by 10% so the line never touches the top edge — looks more
|
||||||
|
// natural and gives the axis a sane ceiling. Floor the dynamic range
|
||||||
|
// at 1 to avoid divide-by-zero on flat-line data (e.g. all zeros).
|
||||||
|
const yDomain = computed(() => {
|
||||||
|
const min = props.valueMin;
|
||||||
|
if (props.valueMax != null) return { min, max: props.valueMax };
|
||||||
|
let max = min;
|
||||||
|
for (const v of dataSlice.value) {
|
||||||
|
const n = Number(v);
|
||||||
|
if (Number.isFinite(n) && n > max) max = n;
|
||||||
|
}
|
||||||
|
if (max <= min) max = min + 1;
|
||||||
|
return { min, max: max * 1.1 };
|
||||||
|
});
|
||||||
|
|
||||||
|
function project(v) {
|
||||||
|
const { min, max } = yDomain.value;
|
||||||
|
const span = max - min;
|
||||||
|
if (span <= 0) return props.paddingTop + drawHeight.value;
|
||||||
|
const clipped = Math.max(min, Math.min(max, Number(v) || 0));
|
||||||
|
const ratio = (clipped - min) / span;
|
||||||
|
return Math.round(props.paddingTop + (drawHeight.value - ratio * drawHeight.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointsArr = computed(() => {
|
||||||
|
const n = nPoints.value;
|
||||||
|
if (n === 0) return [];
|
||||||
|
const slice = dataSlice.value;
|
||||||
|
const w = drawWidth.value;
|
||||||
|
const dx = n > 1 ? w / (n - 1) : 0;
|
||||||
|
return slice.map((v, i) => {
|
||||||
|
const x = Math.round(props.paddingLeft + i * dx);
|
||||||
|
return [x, project(v)];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pointsStr = computed(() => pointsArr.value.map((p) => `${p[0]},${p[1]}`).join(' '));
|
||||||
|
|
||||||
|
const areaPath = computed(() => {
|
||||||
|
if (pointsArr.value.length === 0) return '';
|
||||||
|
const first = pointsArr.value[0];
|
||||||
|
const last = pointsArr.value[pointsArr.value.length - 1];
|
||||||
|
const baseY = props.paddingTop + drawHeight.value;
|
||||||
|
const line = pointsStr.value.replace(/ /g, ' L ');
|
||||||
|
return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridLines = computed(() => {
|
||||||
|
if (!props.showGrid) return [];
|
||||||
|
const h = drawHeight.value;
|
||||||
|
const w = drawWidth.value;
|
||||||
|
return [0, 0.25, 0.5, 0.75, 1].map((r) => {
|
||||||
|
const y = Math.round(props.paddingTop + h * r);
|
||||||
|
return { x1: props.paddingLeft, y1: y, x2: props.paddingLeft + w, y2: y };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastPoint = computed(() => {
|
||||||
|
if (pointsArr.value.length === 0) return null;
|
||||||
|
return pointsArr.value[pointsArr.value.length - 1];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Y-axis tick rendering. We pick a small number of evenly spaced values
|
||||||
|
// inside the resolved domain and run them through yFormatter — that's
|
||||||
|
// what makes "MB/s" / "clients" / "%" all render correctly without the
|
||||||
|
// caller having to subclass the component.
|
||||||
|
const yTicks = computed(() => {
|
||||||
|
if (!props.showAxes) return [];
|
||||||
|
const { min, max } = yDomain.value;
|
||||||
|
const out = [];
|
||||||
|
// For percent-style domains keep the legacy fixed step; otherwise
|
||||||
|
// default to 4 evenly spaced ticks (5 lines including the bottom).
|
||||||
|
if (props.valueMax === 100 && props.valueMin === 0 && props.yTickStep > 0) {
|
||||||
|
for (let p = min; p <= max; p += props.yTickStep) {
|
||||||
|
const y = project(p);
|
||||||
|
out.push({ y, label: props.yFormatter(p) });
|
||||||
|
}
|
||||||
|
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: props.yFormatter(v) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
const xTicks = computed(() => {
|
||||||
|
if (!props.showAxes) return [];
|
||||||
|
const labels = labelsSlice.value;
|
||||||
|
const n = nPoints.value;
|
||||||
|
if (n === 0) return [];
|
||||||
|
const m = Math.max(2, props.tickCountX);
|
||||||
|
const w = drawWidth.value;
|
||||||
|
const dx = n > 1 ? w / (n - 1) : 0;
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
const idx = Math.round((i * (n - 1)) / (m - 1));
|
||||||
|
const label = labels[idx] != null ? String(labels[idx]) : String(idx);
|
||||||
|
const x = Math.round(props.paddingLeft + idx * dx);
|
||||||
|
out.push({ x, label });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onMouseMove(evt) {
|
||||||
|
if (!props.showTooltip || pointsArr.value.length === 0) return;
|
||||||
|
const rect = evt.currentTarget.getBoundingClientRect();
|
||||||
|
const px = evt.clientX - rect.left;
|
||||||
|
const x = (px / rect.width) * effectiveVbWidth.value;
|
||||||
|
const n = nPoints.value;
|
||||||
|
const dx = n > 1 ? drawWidth.value / (n - 1) : 0;
|
||||||
|
const idx = Math.max(0, Math.min(n - 1, Math.round((x - props.paddingLeft) / (dx || 1))));
|
||||||
|
hoverIdx.value = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseLeave() {
|
||||||
|
hoverIdx.value = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtHoverText() {
|
||||||
|
const idx = hoverIdx.value;
|
||||||
|
if (idx < 0 || idx >= dataSlice.value.length) return '';
|
||||||
|
const raw = Number(dataSlice.value[idx] || 0);
|
||||||
|
const fmt = props.tooltipFormatter || props.yFormatter;
|
||||||
|
const val = fmt(Number.isFinite(raw) ? raw : 0);
|
||||||
|
const lab = labelsSlice.value[idx] != null ? labelsSlice.value[idx] : '';
|
||||||
|
return `${val}${lab ? ' • ' + lab : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable per-instance gradient id so multiple sparklines on a page
|
||||||
|
// don't clobber each other's <defs id="spkGrad">.
|
||||||
|
const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg ref="svgRef" width="100%" :height="height" :viewBox="viewBoxAttr" preserveAspectRatio="none"
|
||||||
|
class="sparkline-svg" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
|
||||||
|
<defs>
|
||||||
|
<linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" :stop-color="stroke" :stop-opacity="fillOpacity" />
|
||||||
|
<stop offset="100%" :stop-color="stroke" stop-opacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g v-if="showGrid">
|
||||||
|
<line v-for="(g, i) in gridLines" :key="i" :x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2" :stroke="gridColor"
|
||||||
|
stroke-width="1" class="cpu-grid-line" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g v-if="showAxes">
|
||||||
|
<text v-for="(t, i) in yTicks" :key="'y' + i" class="cpu-grid-y-text" :x="Math.max(0, paddingLeft - 4)"
|
||||||
|
:y="t.y + 4" text-anchor="end" font-size="10">{{ t.label }}</text>
|
||||||
|
<text v-for="(t, i) in xTicks" :key="'x' + i" class="cpu-grid-x-text" :x="t.x" :y="paddingTop + drawHeight + 14"
|
||||||
|
text-anchor="middle" font-size="10">{{ t.label }}</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<path v-if="areaPath" :d="areaPath" :fill="`url(#${gradId})`" stroke="none" />
|
||||||
|
<polyline :points="pointsStr" fill="none" :stroke="stroke" :stroke-width="strokeWidth" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" />
|
||||||
|
<circle v-if="showMarker && lastPoint" :cx="lastPoint[0]" :cy="lastPoint[1]" :r="markerRadius" :fill="stroke" />
|
||||||
|
|
||||||
|
<g v-if="showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx]">
|
||||||
|
<line class="cpu-grid-h-line" :x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]" :y1="paddingTop"
|
||||||
|
:y2="paddingTop + drawHeight" stroke="rgba(0,0,0,0.2)" stroke-width="1" />
|
||||||
|
<circle :cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]" r="3.5" :fill="stroke" />
|
||||||
|
<text class="cpu-grid-text" :x="pointsArr[hoverIdx][0]" :y="paddingTop + 12" text-anchor="middle"
|
||||||
|
font-size="11">{{ fmtHoverText() }}</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sparkline-svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Axis labels live on SVG <text> elements; Vue's scoped CSS doesn't
|
||||||
|
reliably hash-attribute SVG descendants, so the dark-mode overrides
|
||||||
|
have to live in a non-scoped block to actually take effect. The
|
||||||
|
numbers are also small, so the dark-theme fills run at ~85% opacity
|
||||||
|
for legibility (the previous 55% was washed out on navy backgrounds). -->
|
||||||
|
<style>
|
||||||
|
.sparkline-svg .cpu-grid-y-text,
|
||||||
|
.sparkline-svg .cpu-grid-x-text {
|
||||||
|
fill: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkline-svg .cpu-grid-text {
|
||||||
|
fill: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .sparkline-svg .cpu-grid-y-text,
|
||||||
|
body.dark .sparkline-svg .cpu-grid-x-text {
|
||||||
|
fill: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .sparkline-svg .cpu-grid-h-line {
|
||||||
|
stroke: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<script>
|
||||||
|
// Use defineComponent so we can keep the parent + child components in
|
||||||
|
// the same file with the provide() <-> inject relationship intact.
|
||||||
|
import { defineComponent, h, computed, ref, resolveComponent, inject } from 'vue';
|
||||||
|
import { DragOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
const ROW_CLASS = 'sortable-row';
|
||||||
|
|
||||||
|
// Sortable a-table — drag-to-reorder rows using Pointer Events.
|
||||||
|
//
|
||||||
|
// Why a custom component:
|
||||||
|
// - Old impl set draggable: true on every row, which broke text selection
|
||||||
|
// in cells and let HTML5 start drags from anywhere on the row. This
|
||||||
|
// version only initiates drag from an explicit handle, via Pointer
|
||||||
|
// Events (one API for mouse + touch + pen).
|
||||||
|
// - During drag, data-source is reordered live; the source row visually
|
||||||
|
// slides into the target slot. The live reorder IS the visual feedback.
|
||||||
|
// - On commit, emits onsort(sourceIndex, targetIndex) — same signature as
|
||||||
|
// before so existing call sites stay unchanged.
|
||||||
|
// - Keyboard support: ArrowUp/ArrowDown move the focused handle's row by
|
||||||
|
// one; Escape cancels an in-flight drag.
|
||||||
|
|
||||||
|
export const TableSortableTrigger = defineComponent({
|
||||||
|
name: 'TableSortableTrigger',
|
||||||
|
props: {
|
||||||
|
itemIndex: { type: Number, required: true },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const sortable = inject('sortable', null);
|
||||||
|
const ariaLabel = computed(() => `Drag to reorder row ${(props.itemIndex ?? 0) + 1}`);
|
||||||
|
|
||||||
|
function onPointerDown(e) {
|
||||||
|
sortable?.startDrag?.(e, props.itemIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e) {
|
||||||
|
const move = sortable?.moveByKeyboard;
|
||||||
|
if (!move) return;
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
move(-1, props.itemIndex);
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
move(+1, props.itemIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => h(DragOutlined, {
|
||||||
|
class: 'sortable-icon',
|
||||||
|
role: 'button',
|
||||||
|
tabindex: 0,
|
||||||
|
'aria-label': ariaLabel.value,
|
||||||
|
onPointerdown: onPointerDown,
|
||||||
|
onKeydown: onKeyDown,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'TableSortable',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
dataSource: { type: Array, default: () => [] },
|
||||||
|
customRow: { type: Function, default: null },
|
||||||
|
rowKey: { type: [String, Function], default: null },
|
||||||
|
locale: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ filterConfirm: 'OK', filterReset: 'Reset', emptyText: 'No data' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['onsort'],
|
||||||
|
setup(props, { emit, slots, attrs, expose }) {
|
||||||
|
// null when idle; while dragging:
|
||||||
|
// { sourceIndex, targetIndex, pointerId, sourceKey }
|
||||||
|
const drag = ref(null);
|
||||||
|
const rootRef = ref(null);
|
||||||
|
|
||||||
|
const isDragging = computed(() => drag.value !== null);
|
||||||
|
|
||||||
|
// Resolve the row key for a record. Used to identify the source row
|
||||||
|
// even after data-source is reordered live during drag.
|
||||||
|
function keyOf(record, fallback) {
|
||||||
|
const rk = props.rowKey;
|
||||||
|
if (typeof rk === 'function') return rk(record);
|
||||||
|
if (typeof rk === 'string') return record?.[rk];
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachListeners() {
|
||||||
|
document.addEventListener('pointermove', onPointerMove, true);
|
||||||
|
document.addEventListener('pointerup', onPointerUp, true);
|
||||||
|
document.addEventListener('pointercancel', cancelDrag, true);
|
||||||
|
document.addEventListener('keydown', cancelDrag, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detachListeners() {
|
||||||
|
document.removeEventListener('pointermove', onPointerMove, true);
|
||||||
|
document.removeEventListener('pointerup', onPointerUp, true);
|
||||||
|
document.removeEventListener('pointercancel', cancelDrag, true);
|
||||||
|
document.removeEventListener('keydown', cancelDrag, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(e, sourceIndex) {
|
||||||
|
// Primary button only (mouse left / first touch).
|
||||||
|
if (e.button != null && e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const record = props.dataSource?.[sourceIndex];
|
||||||
|
drag.value = {
|
||||||
|
sourceIndex,
|
||||||
|
targetIndex: sourceIndex,
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
sourceKey: keyOf(record, sourceIndex),
|
||||||
|
};
|
||||||
|
// Capture the pointer so move/up keep firing even if the cursor
|
||||||
|
// leaves the icon. Try/catch — some older browsers throw on capture.
|
||||||
|
if (e.target?.setPointerCapture && e.pointerId != null) {
|
||||||
|
try { e.target.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
attachListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e) {
|
||||||
|
const d = drag.value;
|
||||||
|
if (!d) return;
|
||||||
|
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
|
||||||
|
const root = rootRef.value;
|
||||||
|
if (!root) return;
|
||||||
|
const rows = root.querySelectorAll(`tr.${ROW_CLASS}`);
|
||||||
|
if (!rows.length) return;
|
||||||
|
const y = e.clientY;
|
||||||
|
const firstRect = rows[0].getBoundingClientRect();
|
||||||
|
const lastRect = rows[rows.length - 1].getBoundingClientRect();
|
||||||
|
let target = d.targetIndex;
|
||||||
|
if (y < firstRect.top) {
|
||||||
|
target = 0;
|
||||||
|
} else if (y > lastRect.bottom) {
|
||||||
|
target = rows.length - 1;
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const rect = rows[i].getBoundingClientRect();
|
||||||
|
if (y >= rect.top && y <= rect.bottom) {
|
||||||
|
target = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target !== d.targetIndex) {
|
||||||
|
drag.value = { ...d, targetIndex: target };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e) {
|
||||||
|
const d = drag.value;
|
||||||
|
if (!d) return;
|
||||||
|
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
|
||||||
|
detachListeners();
|
||||||
|
const captured = d;
|
||||||
|
drag.value = null;
|
||||||
|
if (captured.sourceIndex !== captured.targetIndex) {
|
||||||
|
emit('onsort', captured.sourceIndex, captured.targetIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDrag(e) {
|
||||||
|
// Triggered by pointercancel and keydown. For keydown only act on
|
||||||
|
// Escape; otherwise let the event propagate.
|
||||||
|
if (e?.type === 'keydown' && e.key !== 'Escape') return;
|
||||||
|
detachListeners();
|
||||||
|
drag.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveByKeyboard(direction, sourceIndex) {
|
||||||
|
const target = sourceIndex + direction;
|
||||||
|
if (target < 0 || target >= (props.dataSource?.length ?? 0)) return;
|
||||||
|
emit('onsort', sourceIndex, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function customRowRender(record, index) {
|
||||||
|
const parent = typeof props.customRow === 'function' ? props.customRow(record, index) || {} : {};
|
||||||
|
const d = drag.value;
|
||||||
|
const isSource = d && keyOf(record, index) === d.sourceKey;
|
||||||
|
// Vue 3 customRow shape: a flat object of attrs/listeners/class —
|
||||||
|
// no nested props/on like Vue 2.
|
||||||
|
return {
|
||||||
|
...parent,
|
||||||
|
class: { [ROW_CLASS]: true, 'sortable-source-row': !!isSource, ...(parent.class || {}) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render-data: dataSource with the source row spliced into targetIndex.
|
||||||
|
// When idle the original list is returned unchanged so a-table can
|
||||||
|
// diff against a stable reference.
|
||||||
|
const records = computed(() => {
|
||||||
|
const d = drag.value;
|
||||||
|
const src = props.dataSource ?? [];
|
||||||
|
if (!d || d.sourceIndex === d.targetIndex) return src;
|
||||||
|
const list = src.slice();
|
||||||
|
const [item] = list.splice(d.sourceIndex, 1);
|
||||||
|
list.splice(d.targetIndex, 0, item);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
expose({ startDrag, moveByKeyboard });
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootRef, drag, isDragging, records, slots, attrs,
|
||||||
|
startDrag, moveByKeyboard, customRowRender,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// provide() needs to live at the options level so child components in
|
||||||
|
// the rendered subtree resolve the same instance methods.
|
||||||
|
provide() {
|
||||||
|
return {
|
||||||
|
sortable: {
|
||||||
|
startDrag: (...a) => this.startDrag(...a),
|
||||||
|
moveByKeyboard: (...a) => this.moveByKeyboard(...a),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
document.removeEventListener('pointermove', this.onPointerMove, true);
|
||||||
|
document.removeEventListener('pointerup', this.onPointerUp, true);
|
||||||
|
document.removeEventListener('pointercancel', this.cancelDrag, true);
|
||||||
|
document.removeEventListener('keydown', this.cancelDrag, true);
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
// Forward every passed slot to a-table by reusing the slot fn
|
||||||
|
// directly. Vue 3 slots are scoped by default so no $scopedSlots dance.
|
||||||
|
const tableSlots = {};
|
||||||
|
for (const name of Object.keys(this.slots)) {
|
||||||
|
tableSlots[name] = this.slots[name];
|
||||||
|
}
|
||||||
|
// Resolved at runtime so the user's app.use(Antd) registration wins;
|
||||||
|
// avoids importing Table directly here.
|
||||||
|
const ATable = resolveComponent('a-table');
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ ref: 'rootRef' },
|
||||||
|
[h(
|
||||||
|
ATable,
|
||||||
|
{
|
||||||
|
...this.attrs,
|
||||||
|
'data-source': this.records,
|
||||||
|
'row-key': this.rowKey,
|
||||||
|
customRow: this.customRowRender,
|
||||||
|
locale: this.locale,
|
||||||
|
class: ['sortable-table', { 'sortable-table-dragging': this.isDragging }],
|
||||||
|
},
|
||||||
|
tableSlots,
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sortable-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: grab;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-icon:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-icon:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-icon:focus-visible {
|
||||||
|
outline: 2px solid #008771;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .sortable-icon {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .sortable-icon:hover {
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-table-dragging .sortable-source-row>td {
|
||||||
|
background: rgba(0, 135, 113, 0.10) !important;
|
||||||
|
transition: background-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-table-dragging .sortable-source-row .routing-index,
|
||||||
|
.sortable-table-dragging .sortable-source-row .outbound-index {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-table-dragging .sortable-row>td {
|
||||||
|
transition: background-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-table-dragging,
|
||||||
|
.sortable-table-dragging * {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup>
|
||||||
|
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { ClipboardManager, FileManager } from '@/utils';
|
||||||
|
|
||||||
|
// Read-only text modal — used to surface multi-line export blobs
|
||||||
|
// (subscription URLs, raw inbound JSON, generated share links) the
|
||||||
|
// way the legacy txtModal did.
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
content: { type: String, default: '' },
|
||||||
|
// When set, surfaces a download button that writes `content` to a
|
||||||
|
// text file with this name.
|
||||||
|
fileName: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open']);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy(value) {
|
||||||
|
const ok = await ClipboardManager.copyText(value || '');
|
||||||
|
if (ok) {
|
||||||
|
message.success('Copied');
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(content, name) {
|
||||||
|
if (!name) return;
|
||||||
|
FileManager.downloadTextFile(content, name);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="title" :closable="true" @cancel="close">
|
||||||
|
<a-textarea :value="content" readonly :auto-size="{ minRows: 10, maxRows: 20 }" class="text-modal-content" />
|
||||||
|
<template #footer>
|
||||||
|
<a-button v-if="fileName" @click="download(content, fileName)">
|
||||||
|
<template #icon>
|
||||||
|
<DownloadOutlined />
|
||||||
|
</template>
|
||||||
|
{{ fileName }}
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" @click="copy(content)">
|
||||||
|
<template #icon>
|
||||||
|
<CopyOutlined />
|
||||||
|
</template>
|
||||||
|
Copy
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-modal-content {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Module-scoped reactive ref for the panel's "Calendar Type" setting.
|
||||||
|
// Loaded from /panel/setting/defaultSettings on first use, so any
|
||||||
|
// component (modals, inbound forms, future pages) can read the same
|
||||||
|
// value without prop-drilling and without re-fetching.
|
||||||
|
//
|
||||||
|
// useInbounds (which already reads defaultSettings for its own state)
|
||||||
|
// calls setDatepicker() after its fetch so we don't issue a second
|
||||||
|
// HTTP round-trip on the inbounds page.
|
||||||
|
|
||||||
|
import { readonly, ref } from 'vue';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
||||||
|
const datepicker = ref('gregorian');
|
||||||
|
let fetched = false;
|
||||||
|
let pending = null;
|
||||||
|
|
||||||
|
async function loadOnce() {
|
||||||
|
if (fetched) return;
|
||||||
|
if (pending) {
|
||||||
|
await pending;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending = (async () => {
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
||||||
|
if (msg?.success) {
|
||||||
|
datepicker.value = msg.obj?.datepicker || 'gregorian';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fetched = true;
|
||||||
|
pending = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
await pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDatepicker(value) {
|
||||||
|
fetched = true;
|
||||||
|
datepicker.value = value || 'gregorian';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDatepicker() {
|
||||||
|
loadOnce();
|
||||||
|
return { datepicker: readonly(datepicker) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ref, onBeforeUnmount, onMounted } from 'vue';
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT_PX = 768;
|
||||||
|
|
||||||
|
// Vue 3 replacement for the legacy MediaQueryMixin. Returns a reactive
|
||||||
|
// `isMobile` ref that updates on window resize. Use inside <script setup>:
|
||||||
|
//
|
||||||
|
// const { isMobile } = useMediaQuery();
|
||||||
|
export function useMediaQuery(breakpoint = MOBILE_BREAKPOINT_PX) {
|
||||||
|
const compute = () => window.innerWidth <= breakpoint;
|
||||||
|
const isMobile = ref(compute());
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
isMobile.value = compute();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { isMobile };
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Lightweight composable that fetches the node list once on mount and
|
||||||
|
// exposes id→name + id→online lookups. Used by the Inbounds page so it
|
||||||
|
// can render a Node selector and a Node column without pulling the
|
||||||
|
// full pages/nodes/useNodes.js (which polls and owns CRUD state).
|
||||||
|
|
||||||
|
import { onMounted, ref, computed } from 'vue';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
||||||
|
export function useNodeList() {
|
||||||
|
const nodes = ref([]);
|
||||||
|
const fetched = ref(false);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/nodes/list');
|
||||||
|
if (msg?.success) {
|
||||||
|
nodes.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||||
|
}
|
||||||
|
fetched.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indexed by id for O(1) UI lookups (Node column on N-row tables).
|
||||||
|
const byId = computed(() => {
|
||||||
|
const m = new Map();
|
||||||
|
for (const n of nodes.value) m.set(n.id, n);
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
function nameFor(id) {
|
||||||
|
if (id == null) return null;
|
||||||
|
return byId.value.get(id)?.name || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOnline(id) {
|
||||||
|
if (id == null) return true;
|
||||||
|
const n = byId.value.get(id);
|
||||||
|
return n != null && n.enable && n.status === 'online';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refresh);
|
||||||
|
|
||||||
|
return { nodes, fetched, refresh, byId, nameFor, isOnline };
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { Status } from '@/models/status.js';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
|
// Polls /panel/api/server/status and exposes a reactive Status object
|
||||||
|
// + a `fetched` flag so consumers can show a spinner before the first
|
||||||
|
// successful fetch.
|
||||||
|
//
|
||||||
|
// WebSocket integration is intentionally deferred to a later sub-phase.
|
||||||
|
// Polling at 2s is the same fallback the legacy panel falls back to
|
||||||
|
// when its websocket link drops, so we're shipping the proven path
|
||||||
|
// first and adding the websocket on top later.
|
||||||
|
export function useStatus() {
|
||||||
|
const status = shallowRef(new Status());
|
||||||
|
const fetched = ref(false);
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/server/status');
|
||||||
|
if (msg?.success) {
|
||||||
|
status.value = new Status(msg.obj);
|
||||||
|
if (!fetched.value) fetched.value = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to get status:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refresh();
|
||||||
|
timer = window.setInterval(refresh, POLL_INTERVAL_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer != null) window.clearInterval(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status, fetched, refresh };
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { reactive, computed, watchEffect } from 'vue';
|
||||||
|
import { theme as antdTheme } from 'ant-design-vue';
|
||||||
|
|
||||||
|
// Single shared theme state. `import { theme } from '@/composables/useTheme.js'`
|
||||||
|
// from any component to read/toggle. Boot side-effects (apply current
|
||||||
|
// theme to <body>/<html>) run once at module load so the page is in the
|
||||||
|
// right theme before Vue mounts.
|
||||||
|
|
||||||
|
const STORAGE_DARK = 'dark-mode';
|
||||||
|
const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
|
||||||
|
|
||||||
|
function readBool(key, fallback) {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (raw === null) return fallback;
|
||||||
|
return raw === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = readBool(STORAGE_DARK, true);
|
||||||
|
const isUltra = readBool(STORAGE_ULTRA, false);
|
||||||
|
|
||||||
|
export const theme = reactive({
|
||||||
|
isDark,
|
||||||
|
isUltra,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const currentTheme = computed(() => (theme.isDark ? 'dark' : 'light'));
|
||||||
|
|
||||||
|
// AD-Vue 4 theme config consumed by every page's <a-config-provider>.
|
||||||
|
// Three modes — light / dark / ultra-dark — all share AD-Vue's vanilla
|
||||||
|
// blue primary. Dark uses a neutral grey palette modelled on VS Code's
|
||||||
|
// Dark+ chrome (`#1e1e1e` editor, `#252526` sidebar, `#2d2d30` panel),
|
||||||
|
// so the panel reads as a familiar modern IDE rather than the older
|
||||||
|
// navy shade. Ultra-dark stays pure-black on darkAlgorithm.
|
||||||
|
const DARK_TOKENS = {
|
||||||
|
colorBgBase: '#1e1e1e',
|
||||||
|
colorBgLayout: '#1e1e1e',
|
||||||
|
colorBgContainer: '#252526',
|
||||||
|
colorBgElevated: '#2d2d30',
|
||||||
|
};
|
||||||
|
const ULTRA_DARK_TOKENS = {
|
||||||
|
colorBgBase: '#000',
|
||||||
|
colorBgLayout: '#000',
|
||||||
|
colorBgContainer: '#0a0a0a',
|
||||||
|
colorBgElevated: '#141414',
|
||||||
|
};
|
||||||
|
|
||||||
|
// AD-Vue 4 hardcodes navy `#001529` / `#002140` as the Layout sider
|
||||||
|
// + trigger backgrounds and `#001529` / `#000c17` as the dark Menu item
|
||||||
|
// backgrounds (see node_modules/ant-design-vue/es/{layout,menu}/style/
|
||||||
|
// index.js). Override at the component-token level so the sider blends
|
||||||
|
// with darkAlgorithm's neutral surfaces. Sider/trigger use the same
|
||||||
|
// `#252526` / `#333333` tones VS Code does for its activity bar.
|
||||||
|
const DARK_LAYOUT_TOKENS = {
|
||||||
|
colorBgHeader: '#252526',
|
||||||
|
colorBgTrigger: '#333333',
|
||||||
|
colorBgBody: '#1e1e1e',
|
||||||
|
};
|
||||||
|
const ULTRA_DARK_LAYOUT_TOKENS = {
|
||||||
|
colorBgHeader: '#0a0a0a',
|
||||||
|
colorBgTrigger: '#141414',
|
||||||
|
colorBgBody: '#000',
|
||||||
|
};
|
||||||
|
const DARK_MENU_TOKENS = {
|
||||||
|
colorItemBg: '#252526',
|
||||||
|
colorSubItemBg: '#1e1e1e',
|
||||||
|
menuSubMenuBg: '#252526',
|
||||||
|
};
|
||||||
|
const ULTRA_DARK_MENU_TOKENS = {
|
||||||
|
colorItemBg: '#0a0a0a',
|
||||||
|
colorSubItemBg: '#000',
|
||||||
|
menuSubMenuBg: '#0a0a0a',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const antdThemeConfig = computed(() => {
|
||||||
|
if (!theme.isDark) {
|
||||||
|
return { algorithm: antdTheme.defaultAlgorithm };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
algorithm: antdTheme.darkAlgorithm,
|
||||||
|
token: theme.isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
|
||||||
|
components: {
|
||||||
|
Layout: theme.isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
|
||||||
|
Menu: theme.isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export function toggleTheme() {
|
||||||
|
theme.isDark = !theme.isDark;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleUltra() {
|
||||||
|
theme.isUltra = !theme.isUltra;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Briefly disable theme transition animations while a toggle is in
|
||||||
|
// flight, then re-enable on mouseleave. Mirrors the legacy panel's
|
||||||
|
// behavior of preventing flicker when hovering the theme menu.
|
||||||
|
export function pauseAnimationsUntilLeave(elementId) {
|
||||||
|
document.documentElement.setAttribute('data-theme-animations', 'off');
|
||||||
|
const el = document.getElementById(elementId);
|
||||||
|
if (!el) return;
|
||||||
|
const restore = () => {
|
||||||
|
document.documentElement.removeAttribute('data-theme-animations');
|
||||||
|
el.removeEventListener('mouseleave', restore);
|
||||||
|
el.removeEventListener('touchend', restore);
|
||||||
|
};
|
||||||
|
el.addEventListener('mouseleave', restore);
|
||||||
|
el.addEventListener('touchend', restore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme to DOM and persist whenever it changes.
|
||||||
|
watchEffect(() => {
|
||||||
|
document.body.setAttribute('class', theme.isDark ? 'dark' : 'light');
|
||||||
|
localStorage.setItem(STORAGE_DARK, String(theme.isDark));
|
||||||
|
|
||||||
|
if (theme.isUltra) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
}
|
||||||
|
localStorage.setItem(STORAGE_ULTRA, String(theme.isUltra));
|
||||||
|
|
||||||
|
// Keep the global #message container's class in sync so AD-Vue toasts
|
||||||
|
// pick up the right styling.
|
||||||
|
const msg = document.getElementById('message');
|
||||||
|
if (msg) msg.className = theme.isDark ? 'dark' : 'light';
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { onBeforeUnmount, onMounted } from 'vue';
|
||||||
|
import { WebSocketClient } from '@/api/websocket.js';
|
||||||
|
|
||||||
|
// One client per browser tab (= per multi-page entry). WebSocketClient is
|
||||||
|
// idempotent: repeated connect() calls while the socket is already open
|
||||||
|
// are no-ops, so multiple components on the same page can share a single
|
||||||
|
// underlying connection without each spawning their own.
|
||||||
|
let sharedClient = null;
|
||||||
|
|
||||||
|
function getSharedClient() {
|
||||||
|
if (sharedClient) return sharedClient;
|
||||||
|
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||||
|
sharedClient = new WebSocketClient(basePath);
|
||||||
|
return sharedClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// useWebSocket lets a Vue component subscribe to live server-pushed
|
||||||
|
// events. Pass a map of { eventName: handler } and the composable wires
|
||||||
|
// connect()/disconnect() into the component lifecycle and unsubscribes
|
||||||
|
// every handler on unmount so a stale closure can't fire after the
|
||||||
|
// page has moved on.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// useWebSocket({
|
||||||
|
// traffic: (payload) => applyTrafficEvent(payload),
|
||||||
|
// client_stats: (payload) => applyClientStatsEvent(payload),
|
||||||
|
// invalidate: ({ type }) => { if (type === 'inbounds') refresh(); },
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// Built-in lifecycle events ('connected' / 'disconnected' / 'error')
|
||||||
|
// can be subscribed to alongside server-emitted types.
|
||||||
|
export function useWebSocket(handlers) {
|
||||||
|
const client = getSharedClient();
|
||||||
|
const entries = Object.entries(handlers || {});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
for (const [event, fn] of entries) client.on(event, fn);
|
||||||
|
client.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
for (const [event, fn] of entries) client.off(event, fn);
|
||||||
|
// Don't disconnect — another mounted component on the same page may
|
||||||
|
// still be subscribed. The client closes naturally on page unload.
|
||||||
|
});
|
||||||
|
|
||||||
|
return { client };
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import Antd, { message } from 'ant-design-vue';
|
||||||
|
import 'ant-design-vue/dist/reset.css';
|
||||||
|
|
||||||
|
import { setupAxios } from '@/api/axios-init.js';
|
||||||
|
import '@/composables/useTheme.js';
|
||||||
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
|
||||||
|
|
||||||
|
setupAxios();
|
||||||
|
|
||||||
|
const messageContainer = document.getElementById('message');
|
||||||
|
if (messageContainer) {
|
||||||
|
message.config({ getContainer: () => messageContainer });
|
||||||
|
}
|
||||||
|
|
||||||
|
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import Antd, { message } from 'ant-design-vue';
|
||||||
|
import 'ant-design-vue/dist/reset.css';
|
||||||
|
|
||||||
|
import { setupAxios } from '@/api/axios-init.js';
|
||||||
|
import '@/composables/useTheme.js';
|
||||||
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
|
||||||
|
|
||||||
|
setupAxios();
|
||||||
|
|
||||||
|
const messageContainer = document.getElementById('message');
|
||||||
|
if (messageContainer) {
|
||||||
|
message.config({ getContainer: () => messageContainer });
|
||||||
|
}
|
||||||
|
|
||||||
|
createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import Antd, { message } from 'ant-design-vue';
|
||||||
|
import 'ant-design-vue/dist/reset.css';
|
||||||
|
|
||||||
|
import { setupAxios } from '@/api/axios-init.js';
|
||||||
|
// Importing useTheme triggers the boot side-effect that applies the
|
||||||
|
// stored theme to <body>/<html> before Vue mounts.
|
||||||
|
import '@/composables/useTheme.js';
|
||||||
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import IndexPage from '@/pages/index/IndexPage.vue';
|
||||||
|
|
||||||
|
setupAxios();
|
||||||
|
|
||||||
|
const messageContainer = document.getElementById('message');
|
||||||
|
if (messageContainer) {
|
||||||
|
message.config({ getContainer: () => messageContainer });
|
||||||
|
}
|
||||||
|
|
||||||
|
createApp(IndexPage).use(Antd).use(i18n).mount('#app');
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import Antd, { message } from 'ant-design-vue';
|
||||||
|
import 'ant-design-vue/dist/reset.css';
|
||||||
|
|
||||||
|
import { setupAxios } from '@/api/axios-init.js';
|
||||||
|
// Importing this module triggers the boot side-effect that applies the
|
||||||
|
// stored theme to <body>/<html> before Vue renders anything.
|
||||||
|
import '@/composables/useTheme.js';
|
||||||
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import LoginPage from '@/pages/login/LoginPage.vue';
|
||||||
|
|
||||||
|
setupAxios();
|
||||||
|
|
||||||
|
// Toasts attach to a #message div the page provides — keeps theme
|
||||||
|
// styling in sync with the rest of the panel.
|
||||||
|
const messageContainer = document.getElementById('message');
|
||||||
|
if (messageContainer) {
|
||||||
|
message.config({ getContainer: () => messageContainer });
|
||||||
|
}
|
||||||
|
|
||||||
|
createApp(LoginPage).use(Antd).use(i18n).mount('#app');
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import Antd, { message } from 'ant-design-vue';
|
||||||
|
import 'ant-design-vue/dist/reset.css';
|
||||||
|
|
||||||
|
import { setupAxios } from '@/api/axios-init.js';
|
||||||
|
import '@/composables/useTheme.js';
|
||||||
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import NodesPage from '@/pages/nodes/NodesPage.vue';
|
||||||
|
|
||||||
|
setupAxios();
|
||||||
|
|
||||||
|
const messageContainer = document.getElementById('message');
|
||||||
|
if (messageContainer) {
|
||||||
|
message.config({ getContainer: () => messageContainer });
|
||||||
|
}
|
||||||
|
|
||||||
|
createApp(NodesPage).use(Antd).use(i18n).mount('#app');
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import Antd, { message } from 'ant-design-vue';
|
||||||
|
import 'ant-design-vue/dist/reset.css';
|
||||||
|
|
||||||
|
import { setupAxios } from '@/api/axios-init.js';
|
||||||
|
// Importing useTheme triggers the boot side-effect that applies the
|
||||||
|
// stored theme to <body>/<html> before Vue mounts.
|
||||||
|
import '@/composables/useTheme.js';
|
||||||
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import SettingsPage from '@/pages/settings/SettingsPage.vue';
|
||||||
|
|
||||||
|
setupAxios();
|
||||||
|
|
||||||
|
const messageContainer = document.getElementById('message');
|
||||||
|
if (messageContainer) {
|
||||||
|
message.config({ getContainer: () => messageContainer });
|
||||||
|
}
|
||||||
|
|
||||||
|
createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import Antd, { message } from 'ant-design-vue';
|
||||||
|
import 'ant-design-vue/dist/reset.css';
|
||||||
|
|
||||||
|
// The sub page is served by the subscription HTTP server (sub/sub.go)
|
||||||
|
// at /<linksPath>/<subId>?html=1. Go injects window.__SUB_PAGE_DATA__
|
||||||
|
// with the parsed traffic/quota/expiry view-model and the rendered
|
||||||
|
// share links — the SPA reads those at mount.
|
||||||
|
import '@/composables/useTheme.js';
|
||||||
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import SubPage from '@/pages/sub/SubPage.vue';
|
||||||
|
|
||||||
|
const messageContainer = document.getElementById('message');
|
||||||
|
if (messageContainer) {
|
||||||
|
message.config({ getContainer: () => messageContainer });
|
||||||
|
}
|
||||||
|
|
||||||
|
createApp(SubPage).use(Antd).use(i18n).mount('#app');
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import Antd, { message } from 'ant-design-vue';
|
||||||
|
import 'ant-design-vue/dist/reset.css';
|
||||||
|
|
||||||
|
import { setupAxios } from '@/api/axios-init.js';
|
||||||
|
import '@/composables/useTheme.js';
|
||||||
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import XrayPage from '@/pages/xray/XrayPage.vue';
|
||||||
|
|
||||||
|
setupAxios();
|
||||||
|
|
||||||
|
const messageContainer = document.getElementById('message');
|
||||||
|
if (messageContainer) {
|
||||||
|
message.config({ getContainer: () => messageContainer });
|
||||||
|
}
|
||||||
|
|
||||||
|
createApp(XrayPage).use(Antd).use(i18n).mount('#app');
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// vue-i18n setup. Locale files live in web/translation/*.json — the same
|
||||||
|
// directory the Go binary embeds, so SPA + Telegram bot + subscription
|
||||||
|
// page all read from a single source.
|
||||||
|
//
|
||||||
|
// Usage in a component:
|
||||||
|
// import { useI18n } from 'vue-i18n';
|
||||||
|
// const { t } = useI18n();
|
||||||
|
// ...
|
||||||
|
// <span>{{ t('pages.inbounds.email') }}</span>
|
||||||
|
//
|
||||||
|
// Or via the global helper exposed on the app:
|
||||||
|
// <span>{{ $t('pages.inbounds.email') }}</span>
|
||||||
|
//
|
||||||
|
// The locale follows the `lang` cookie that LanguageManager already
|
||||||
|
// reads/writes — switching language anywhere in the app continues to
|
||||||
|
// trigger a full page reload (matches legacy ergonomics), so we don't
|
||||||
|
// need a runtime locale switcher here.
|
||||||
|
|
||||||
|
import { createI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { LanguageManager } from '@/utils';
|
||||||
|
|
||||||
|
// Lazy-loaded locales — Vite splits each one into its own chunk. We
|
||||||
|
// eager-load only the active language plus the en-US fallback so the
|
||||||
|
// initial page payload stays small (the inbounds bundle was sitting
|
||||||
|
// at ~700kB gzipped with all 13 locales eager; now ~480kB).
|
||||||
|
//
|
||||||
|
// LanguageManager.setLanguage() does a full reload on change, so
|
||||||
|
// "lazy" here effectively means "load only what this page needs for
|
||||||
|
// its lifetime."
|
||||||
|
const FALLBACK = 'en-US';
|
||||||
|
const lazyModules = import.meta.glob('../../../web/translation/*.json');
|
||||||
|
const eagerModules = import.meta.glob('../../../web/translation/*.json', { eager: true });
|
||||||
|
|
||||||
|
function moduleKeyFor(code) {
|
||||||
|
return `../../../web/translation/${code}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the active locale via LanguageManager so the cookie set on
|
||||||
|
// the legacy panel keeps working after a user upgrades. Falls back
|
||||||
|
// to en-US when the cookie names a language we don't have.
|
||||||
|
let active = LanguageManager.getLanguage();
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
|
||||||
|
active = FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = {};
|
||||||
|
// Eagerly include the active locale + the fallback (when distinct)
|
||||||
|
// so the very first render has strings ready. Vite still emits these
|
||||||
|
// as their own chunks so the user pays for at most two locales.
|
||||||
|
for (const code of new Set([active, FALLBACK])) {
|
||||||
|
const mod = eagerModules[moduleKeyFor(code)];
|
||||||
|
if (mod) messages[code] = mod.default || mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
// `composition` mode (legacy: false) so `useI18n()` works in
|
||||||
|
// <script setup> blocks.
|
||||||
|
globalInjection: true,
|
||||||
|
locale: active,
|
||||||
|
fallbackLocale: FALLBACK,
|
||||||
|
// Locale JSON is nested by namespace ({pages: {inbounds: {email: ...}}})
|
||||||
|
// so vue-i18n's default `.`-delimited lookups walk straight into it.
|
||||||
|
messages,
|
||||||
|
// The Go side sometimes interpolates `#variable#` into translated
|
||||||
|
// strings (e.g. xraySwitchVersionDialogDesc). vue-i18n's default
|
||||||
|
// expects `{var}` — disable warnings about strings that look like
|
||||||
|
// they don't use the new syntax.
|
||||||
|
warnHtmlMessage: false,
|
||||||
|
missingWarn: false,
|
||||||
|
fallbackWarn: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convenience export for non-component contexts (HTTP error toasts,
|
||||||
|
// stores, etc.) that need to look up a translation outside a setup
|
||||||
|
// scope.
|
||||||
|
export function t(key, params) {
|
||||||
|
return i18n.global.t(key, params || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadLocale fetches a locale module on demand and registers it with
|
||||||
|
// vue-i18n. Pages that switch language at runtime (rather than via
|
||||||
|
// LanguageManager's reload) can call this to swap strings live.
|
||||||
|
export async function loadLocale(code) {
|
||||||
|
const key = moduleKeyFor(code);
|
||||||
|
const loader = lazyModules[key];
|
||||||
|
if (!loader) return false;
|
||||||
|
const mod = await loader();
|
||||||
|
i18n.global.setLocaleMessage(code, mod.default || mod);
|
||||||
|
i18n.global.locale.value = code;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
class DBInbound {
|
import dayjs from 'dayjs';
|
||||||
|
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
|
||||||
|
import { Inbound, Protocols } from './inbound.js';
|
||||||
|
|
||||||
|
export class DBInbound {
|
||||||
|
|
||||||
constructor(data) {
|
constructor(data) {
|
||||||
this.id = 0;
|
this.id = 0;
|
||||||
@@ -21,6 +25,9 @@ class DBInbound {
|
|||||||
this.tag = "";
|
this.tag = "";
|
||||||
this.sniffing = "";
|
this.sniffing = "";
|
||||||
this.clientStats = ""
|
this.clientStats = ""
|
||||||
|
// Optional FK to web/runtime registered Node. null/undefined =
|
||||||
|
// local panel; otherwise the inbound lives on the named node.
|
||||||
|
this.nodeId = null;
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -75,7 +82,7 @@ class DBInbound {
|
|||||||
if (this.expiryTime === 0) {
|
if (this.expiryTime === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return moment(this.expiryTime);
|
return dayjs(this.expiryTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
set _expiryTime(t) {
|
set _expiryTime(t) {
|
||||||
@@ -90,7 +97,16 @@ class DBInbound {
|
|||||||
return this.expiryTime < new Date().getTime();
|
return this.expiryTime < new Date().getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateCache() {
|
||||||
|
this._cachedInbound = null;
|
||||||
|
this._clientStatsMap = null;
|
||||||
|
}
|
||||||
|
|
||||||
toInbound() {
|
toInbound() {
|
||||||
|
if (this._cachedInbound) {
|
||||||
|
return this._cachedInbound;
|
||||||
|
}
|
||||||
|
|
||||||
let settings = {};
|
let settings = {};
|
||||||
if (!ObjectUtil.isEmpty(this.settings)) {
|
if (!ObjectUtil.isEmpty(this.settings)) {
|
||||||
settings = JSON.parse(this.settings);
|
settings = JSON.parse(this.settings);
|
||||||
@@ -116,7 +132,21 @@ class DBInbound {
|
|||||||
sniffing: sniffing,
|
sniffing: sniffing,
|
||||||
clientStats: this.clientStats,
|
clientStats: this.clientStats,
|
||||||
};
|
};
|
||||||
return Inbound.fromJson(config);
|
|
||||||
|
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() {
|
isMultiUser() {
|
||||||
@@ -124,6 +154,7 @@ class DBInbound {
|
|||||||
case Protocols.VMESS:
|
case Protocols.VMESS:
|
||||||
case Protocols.VLESS:
|
case Protocols.VLESS:
|
||||||
case Protocols.TROJAN:
|
case Protocols.TROJAN:
|
||||||
|
case Protocols.HYSTERIA:
|
||||||
return true;
|
return true;
|
||||||
case Protocols.SHADOWSOCKS:
|
case Protocols.SHADOWSOCKS:
|
||||||
return this.toInbound().isSSMultiUser;
|
return this.toInbound().isSSMultiUser;
|
||||||
@@ -138,14 +169,15 @@ class DBInbound {
|
|||||||
case Protocols.VLESS:
|
case Protocols.VLESS:
|
||||||
case Protocols.TROJAN:
|
case Protocols.TROJAN:
|
||||||
case Protocols.SHADOWSOCKS:
|
case Protocols.SHADOWSOCKS:
|
||||||
|
case Protocols.HYSTERIA:
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
genInboundLinks(remarkModel) {
|
genInboundLinks(remarkModel, hostOverride = '') {
|
||||||
const inbound = this.toInbound();
|
const inbound = this.toInbound();
|
||||||
return inbound.genInboundLinks(this.remark, remarkModel);
|
return inbound.genInboundLinks(this.remark, remarkModel, hostOverride);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
// 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
class AllSetting {
|
// Mirrors web/assets/js/model/setting.js — every field on this class is
|
||||||
|
// round-tripped through `/panel/setting/all` and `/panel/setting/update`,
|
||||||
|
// so adding a field here without a matching Go-side change will silently
|
||||||
|
// drop it on save. Defaults match the legacy panel.
|
||||||
|
|
||||||
|
import { ObjectUtil } from '@/utils';
|
||||||
|
|
||||||
|
export class AllSetting {
|
||||||
|
|
||||||
constructor(data) {
|
constructor(data) {
|
||||||
this.webListen = "";
|
this.webListen = "";
|
||||||
@@ -29,13 +36,21 @@ class AllSetting {
|
|||||||
this.subEnable = true;
|
this.subEnable = true;
|
||||||
this.subJsonEnable = false;
|
this.subJsonEnable = false;
|
||||||
this.subTitle = "";
|
this.subTitle = "";
|
||||||
|
this.subSupportUrl = "";
|
||||||
|
this.subProfileUrl = "";
|
||||||
|
this.subAnnounce = "";
|
||||||
|
this.subEnableRouting = true;
|
||||||
|
this.subRoutingRules = "";
|
||||||
this.subListen = "";
|
this.subListen = "";
|
||||||
this.subPort = 2096;
|
this.subPort = 2096;
|
||||||
this.subPath = "/sub/";
|
this.subPath = "/sub/";
|
||||||
this.subJsonPath = "/json/";
|
this.subJsonPath = "/json/";
|
||||||
|
this.subClashEnable = true;
|
||||||
|
this.subClashPath = "/clash/";
|
||||||
this.subDomain = "";
|
this.subDomain = "";
|
||||||
this.externalTrafficInformEnable = false;
|
this.externalTrafficInformEnable = false;
|
||||||
this.externalTrafficInformURI = "";
|
this.externalTrafficInformURI = "";
|
||||||
|
this.restartXrayOnClientDisable = true;
|
||||||
this.subCertFile = "";
|
this.subCertFile = "";
|
||||||
this.subKeyFile = "";
|
this.subKeyFile = "";
|
||||||
this.subUpdates = 12;
|
this.subUpdates = 12;
|
||||||
@@ -43,6 +58,7 @@ class AllSetting {
|
|||||||
this.subShowInfo = true;
|
this.subShowInfo = true;
|
||||||
this.subURI = "";
|
this.subURI = "";
|
||||||
this.subJsonURI = "";
|
this.subJsonURI = "";
|
||||||
|
this.subClashURI = "";
|
||||||
this.subJsonFragment = "";
|
this.subJsonFragment = "";
|
||||||
this.subJsonNoises = "";
|
this.subJsonNoises = "";
|
||||||
this.subJsonMux = "";
|
this.subJsonMux = "";
|
||||||
@@ -50,6 +66,28 @@ class AllSetting {
|
|||||||
|
|
||||||
this.timeLocation = "Local";
|
this.timeLocation = "Local";
|
||||||
|
|
||||||
|
// LDAP settings
|
||||||
|
this.ldapEnable = false;
|
||||||
|
this.ldapHost = "";
|
||||||
|
this.ldapPort = 389;
|
||||||
|
this.ldapUseTLS = false;
|
||||||
|
this.ldapBindDN = "";
|
||||||
|
this.ldapPassword = "";
|
||||||
|
this.ldapBaseDN = "";
|
||||||
|
this.ldapUserFilter = "(objectClass=person)";
|
||||||
|
this.ldapUserAttr = "mail";
|
||||||
|
this.ldapVlessField = "vless_enabled";
|
||||||
|
this.ldapSyncCron = "@every 1m";
|
||||||
|
this.ldapFlagField = "";
|
||||||
|
this.ldapTruthyValues = "true,1,yes,on";
|
||||||
|
this.ldapInvertFlag = false;
|
||||||
|
this.ldapInboundTags = "";
|
||||||
|
this.ldapAutoCreate = false;
|
||||||
|
this.ldapAutoDelete = false;
|
||||||
|
this.ldapDefaultTotalGB = 0;
|
||||||
|
this.ldapDefaultExpiryDays = 0;
|
||||||
|
this.ldapDefaultLimitIP = 0;
|
||||||
|
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { NumberFormatter } from '@/utils';
|
||||||
|
|
||||||
|
export class CurTotal {
|
||||||
|
constructor(current, total) {
|
||||||
|
this.current = current;
|
||||||
|
this.total = total;
|
||||||
|
}
|
||||||
|
|
||||||
|
get percent() {
|
||||||
|
if (this.total === 0) return 0;
|
||||||
|
return NumberFormatter.toFixed((this.current / this.total) * 100, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
get color() {
|
||||||
|
// Match AD-Vue 4's semantic palette so the gauges fit the
|
||||||
|
// global blue/gold/red theme instead of the legacy teal/orange.
|
||||||
|
const p = this.percent;
|
||||||
|
if (p < 80) return '#1677ff'; // primary
|
||||||
|
if (p < 90) return '#faad14'; // warning
|
||||||
|
return '#ff4d4f'; // danger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const XRAY_STATE_COLORS = {
|
||||||
|
running: 'green',
|
||||||
|
stop: 'orange',
|
||||||
|
error: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Status {
|
||||||
|
constructor(data) {
|
||||||
|
this.cpu = new CurTotal(0, 0);
|
||||||
|
this.cpuCores = 0;
|
||||||
|
this.logicalPro = 0;
|
||||||
|
this.cpuSpeedMhz = 0;
|
||||||
|
this.disk = new CurTotal(0, 0);
|
||||||
|
this.loads = [0, 0, 0];
|
||||||
|
this.mem = new CurTotal(0, 0);
|
||||||
|
this.netIO = { up: 0, down: 0 };
|
||||||
|
this.netTraffic = { sent: 0, recv: 0 };
|
||||||
|
this.publicIP = { ipv4: 0, ipv6: 0 };
|
||||||
|
this.swap = new CurTotal(0, 0);
|
||||||
|
this.tcpCount = 0;
|
||||||
|
this.udpCount = 0;
|
||||||
|
this.uptime = 0;
|
||||||
|
this.appUptime = 0;
|
||||||
|
this.appStats = { threads: 0, mem: 0, uptime: 0 };
|
||||||
|
this.xray = { state: 'stop', errorMsg: '', version: '', color: '' };
|
||||||
|
|
||||||
|
if (data == null) return;
|
||||||
|
|
||||||
|
this.cpu = new CurTotal(data.cpu, 100);
|
||||||
|
this.cpuCores = data.cpuCores;
|
||||||
|
this.logicalPro = data.logicalPro;
|
||||||
|
this.cpuSpeedMhz = data.cpuSpeedMhz;
|
||||||
|
this.disk = new CurTotal(data.disk?.current ?? 0, data.disk?.total ?? 0);
|
||||||
|
this.loads = (data.loads || [0, 0, 0]).map((v) => NumberFormatter.toFixed(v, 2));
|
||||||
|
this.mem = new CurTotal(data.mem?.current ?? 0, data.mem?.total ?? 0);
|
||||||
|
this.netIO = data.netIO ?? this.netIO;
|
||||||
|
this.netTraffic = data.netTraffic ?? this.netTraffic;
|
||||||
|
this.publicIP = data.publicIP ?? this.publicIP;
|
||||||
|
this.swap = new CurTotal(data.swap?.current ?? 0, data.swap?.total ?? 0);
|
||||||
|
this.tcpCount = data.tcpCount ?? 0;
|
||||||
|
this.udpCount = data.udpCount ?? 0;
|
||||||
|
this.uptime = data.uptime ?? 0;
|
||||||
|
this.appUptime = data.appUptime ?? 0;
|
||||||
|
this.appStats = data.appStats ?? this.appStats;
|
||||||
|
this.xray = { ...this.xray, ...(data.xray || {}) };
|
||||||
|
this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Modal, message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
KeyOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
EyeInvisibleOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||||
|
import AppSidebar from '@/components/AppSidebar.vue';
|
||||||
|
import { HttpUtil, ClipboardManager } from '@/utils/index.js';
|
||||||
|
import { sections } from './endpoints.js';
|
||||||
|
import EndpointSection from './EndpointSection.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
|
const requestUri = window.location.pathname;
|
||||||
|
|
||||||
|
const apiToken = ref('');
|
||||||
|
const tokenLoading = ref(false);
|
||||||
|
const tokenRotating = ref(false);
|
||||||
|
const tokenVisible = ref(false);
|
||||||
|
|
||||||
|
const curlExample = `curl -X GET \\
|
||||||
|
-H "Authorization: Bearer YOUR_API_TOKEN" \\
|
||||||
|
-H "Accept: application/json" \\
|
||||||
|
https://your-panel.example.com/panel/api/inbounds/list`;
|
||||||
|
|
||||||
|
async function loadApiToken() {
|
||||||
|
tokenLoading.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get('/panel/setting/getApiToken');
|
||||||
|
if (msg?.success) apiToken.value = msg.obj || '';
|
||||||
|
} finally {
|
||||||
|
tokenLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerateApiToken() {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('pages.nodes.regenerateConfirm'),
|
||||||
|
okText: t('confirm'),
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
okType: 'danger',
|
||||||
|
onOk: async () => {
|
||||||
|
tokenRotating.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
|
||||||
|
if (msg?.success) {
|
||||||
|
apiToken.value = msg.obj || '';
|
||||||
|
message.success(t('success'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
tokenRotating.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyApiToken() {
|
||||||
|
if (!apiToken.value) return;
|
||||||
|
const ok = await ClipboardManager.copy(apiToken.value);
|
||||||
|
if (ok) message.success(t('success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToSection(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadApiToken();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-config-provider :theme="antdThemeConfig">
|
||||||
|
<a-layout class="api-docs-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||||
|
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||||
|
|
||||||
|
<a-layout class="content-shell">
|
||||||
|
<a-layout-content class="content-area">
|
||||||
|
<div class="docs-wrapper">
|
||||||
|
<header class="docs-header">
|
||||||
|
<h1 class="docs-title">API Documentation</h1>
|
||||||
|
<p class="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>
|
||||||
|
|
||||||
|
<a-card class="token-card" size="small">
|
||||||
|
<div class="token-card-head">
|
||||||
|
<div class="token-card-title">
|
||||||
|
<KeyOutlined />
|
||||||
|
<span>API Token</span>
|
||||||
|
</div>
|
||||||
|
<a-space size="small" wrap>
|
||||||
|
<a-button size="small" @click="tokenVisible = !tokenVisible">
|
||||||
|
<template #icon>
|
||||||
|
<EyeInvisibleOutlined v-if="tokenVisible" />
|
||||||
|
<EyeOutlined v-else />
|
||||||
|
</template>
|
||||||
|
{{ tokenVisible ? 'Hide' : 'Show' }}
|
||||||
|
</a-button>
|
||||||
|
<a-button size="small" :disabled="!apiToken" @click="copyApiToken">
|
||||||
|
<template #icon>
|
||||||
|
<CopyOutlined />
|
||||||
|
</template>
|
||||||
|
Copy
|
||||||
|
</a-button>
|
||||||
|
<a-button size="small" danger :loading="tokenRotating" @click="regenerateApiToken">
|
||||||
|
<template #icon>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</template>
|
||||||
|
Regenerate
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
<a-spin :spinning="tokenLoading" size="small">
|
||||||
|
<pre
|
||||||
|
class="token-value">{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}</pre>
|
||||||
|
</a-spin>
|
||||||
|
<p class="token-hint">
|
||||||
|
Send it on every request as <code>Authorization: Bearer <token></code>. Token-authenticated
|
||||||
|
callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately —
|
||||||
|
running bots will need the new value.
|
||||||
|
</p>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card class="curl-card" size="small" title="Quick example">
|
||||||
|
<pre class="code-block">{{ curlExample }}</pre>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<nav class="toc-nav">
|
||||||
|
<span class="toc-label">On this page:</span>
|
||||||
|
<a v-for="s in sections" :key="s.id" class="toc-link" :href="`#${s.id}`"
|
||||||
|
@click.prevent="scrollToSection(s.id)">
|
||||||
|
{{ s.title }}
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<EndpointSection v-for="s in sections" :key="s.id" :section="s" />
|
||||||
|
</div>
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
</a-layout>
|
||||||
|
</a-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.api-docs-page {
|
||||||
|
--bg-page: #e6e8ec;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-docs-page.is-dark {
|
||||||
|
--bg-page: #1e1e1e;
|
||||||
|
--bg-card: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-docs-page.is-dark.is-ultra {
|
||||||
|
--bg-page: #000;
|
||||||
|
--bg-card: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-shell {
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content-area {
|
||||||
|
padding: 16px 12px 12px;
|
||||||
|
padding-top: 64px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-wrapper {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-header {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-title {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-lead {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
line-height: 1.6;
|
||||||
|
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: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-card-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-value {
|
||||||
|
background: rgba(128, 128, 128, 0.08);
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-hint {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: rgba(0, 0, 0, 0.55);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: rgba(128, 128, 128, 0.08);
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(128, 128, 128, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-link {
|
||||||
|
color: #1677ff;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-link:hover {
|
||||||
|
color: #4096ff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body.dark .docs-title {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .docs-lead,
|
||||||
|
body.dark .token-hint {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .docs-lead code,
|
||||||
|
body.dark .token-hint code {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .token-value,
|
||||||
|
body.dark .code-block {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .toc-nav {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .toc-label {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { methodColors } from './endpoints.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
endpoint: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const tagColor = computed(() => methodColors[props.endpoint.method] || 'default');
|
||||||
|
const hasParams = computed(() => Array.isArray(props.endpoint.params) && props.endpoint.params.length > 0);
|
||||||
|
|
||||||
|
const paramColumns = [
|
||||||
|
{ 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' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="endpoint-row">
|
||||||
|
<div class="endpoint-header">
|
||||||
|
<a-tag :color="tagColor" class="method-tag">{{ endpoint.method }}</a-tag>
|
||||||
|
<code class="endpoint-path">{{ endpoint.path }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="endpoint.summary" class="endpoint-summary">{{ endpoint.summary }}</p>
|
||||||
|
|
||||||
|
<div v-if="hasParams" class="endpoint-block">
|
||||||
|
<div class="block-label">Parameters</div>
|
||||||
|
<a-table :columns="paramColumns" :data-source="endpoint.params" :pagination="false" size="small" row-key="name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="endpoint.body" class="endpoint-block">
|
||||||
|
<div class="block-label">Request body</div>
|
||||||
|
<a-typography-paragraph :copyable="{ text: endpoint.body }">
|
||||||
|
<pre class="code-block">{{ endpoint.body }}</pre>
|
||||||
|
</a-typography-paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="endpoint.response" class="endpoint-block">
|
||||||
|
<div class="block-label">Response</div>
|
||||||
|
<a-typography-paragraph :copyable="{ text: endpoint.response }">
|
||||||
|
<pre class="code-block">{{ endpoint.response }}</pre>
|
||||||
|
</a-typography-paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.endpoint-row {
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-row + .endpoint-row {
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-tag {
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-path {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-summary {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-block {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: rgba(128, 128, 128, 0.08);
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body.dark .endpoint-summary {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .block-label {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .code-block {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup>
|
||||||
|
import EndpointRow from './EndpointRow.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
section: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section :id="section.id" class="api-section">
|
||||||
|
<h2 class="section-title">{{ section.title }}</h2>
|
||||||
|
<p v-if="section.description" class="section-description">{{ section.description }}</p>
|
||||||
|
<div class="endpoints">
|
||||||
|
<EndpointRow v-for="(endpoint, idx) in section.endpoints" :key="idx" :endpoint="endpoint" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.api-section {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
scroll-margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
margin: 6px 0 14px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoints > :first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body.dark .api-section {
|
||||||
|
background: #252526;
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .api-section {
|
||||||
|
background: #0a0a0a;
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .section-title {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .section-description {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
export const sections = [
|
||||||
|
{
|
||||||
|
id: 'auth',
|
||||||
|
title: 'Authentication',
|
||||||
|
description:
|
||||||
|
'Two authentication modes are supported. UI sessions use a cookie set by the login endpoint. Programmatic clients (bots, scripts, remote panels) authenticate with a Bearer token taken from Settings → Security → API Token. Both work for every endpoint under /panel/api/*.',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/login',
|
||||||
|
summary: 'Authenticate with username + password and receive a session cookie. Required before any cookie-based API call.',
|
||||||
|
params: [
|
||||||
|
{ name: 'username', in: 'body', type: 'string', desc: 'Panel admin username.' },
|
||||||
|
{ name: 'password', in: 'body', type: 'string', desc: 'Panel admin password.' },
|
||||||
|
{ name: 'twoFactorCode', in: 'body', type: 'string', desc: 'OTP code when 2FA is enabled. Omit otherwise.' },
|
||||||
|
],
|
||||||
|
body: '{\n "username": "admin",\n "password": "admin",\n "twoFactorCode": "123456"\n}',
|
||||||
|
response:
|
||||||
|
'{\n "success": true,\n "msg": "Logged in successfully"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/logout',
|
||||||
|
summary: 'Clear the session cookie. Redirects back to the login page; not useful from non-browser clients.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/csrf-token',
|
||||||
|
summary: 'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.',
|
||||||
|
response:
|
||||||
|
'{\n "success": true,\n "obj": "csrf-token-string"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/getTwoFactorEnable',
|
||||||
|
summary: 'Returns whether 2FA is enabled on the panel — used by the login page to decide whether to show the OTP field.',
|
||||||
|
response: '{\n "success": true,\n "obj": false\n}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'inbounds',
|
||||||
|
title: 'Inbounds API',
|
||||||
|
description:
|
||||||
|
'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour X-Forwarded-Host / X-Forwarded-Proto, so callers behind a reverse proxy get the correct external host in returned URLs.',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/inbounds/list',
|
||||||
|
summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters.',
|
||||||
|
response:
|
||||||
|
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": "{\\"clients\\":[...]}",\n "streamSettings": "{...}",\n "tag": "inbound-443",\n "sniffing": "{...}",\n "clientStats": [...]\n }\n ]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/inbounds/get/:id',
|
||||||
|
summary: 'Fetch a single inbound by numeric ID.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/inbounds/getClientTraffics/:email',
|
||||||
|
summary: 'Traffic counters for a client identified by email.',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/inbounds/getClientTrafficsById/:id',
|
||||||
|
summary: 'Traffic counters for a client identified by its UUID/password.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/add',
|
||||||
|
summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).',
|
||||||
|
body:
|
||||||
|
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/del/:id',
|
||||||
|
summary: 'Delete an inbound by ID. Also removes its associated client stats rows.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/update/:id',
|
||||||
|
summary: 'Replace an inbound’s configuration. Body shape mirrors /add. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/setEnable/:id',
|
||||||
|
summary: 'Toggle only the enable flag without serialising the whole settings JSON. Recommended for UI switches on large inbounds.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||||
|
],
|
||||||
|
body: '{\n "enable": false\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/clientIps/:email',
|
||||||
|
summary: 'List source IPs that have connected with the given client’s credentials. Returns an array of "ip (timestamp)" strings.',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/clearClientIps/:email',
|
||||||
|
summary: 'Reset the recorded IP list for a client.',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/addClient',
|
||||||
|
summary: 'Add one or more clients to an existing inbound. The settings field is the JSON-encoded settings.clients array of the target inbound.',
|
||||||
|
body:
|
||||||
|
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"newuser\\",\\"limitIp\\":0,\\"totalGB\\":0,\\"expiryTime\\":0,\\"enable\\":true,\\"flow\\":\\"\\"}]}"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/:id/copyClients',
|
||||||
|
summary: 'Copy selected clients from one inbound into another. Useful for duplicating user lists across protocols.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Target inbound ID.' },
|
||||||
|
{ name: 'sourceInboundId', in: 'body', type: 'number', desc: 'Inbound ID to read clients from.' },
|
||||||
|
{ name: 'clientEmails', in: 'body', type: 'string[]', desc: 'Emails of clients to copy. Empty means all clients.' },
|
||||||
|
{ name: 'flow', in: 'body', type: 'string', desc: 'Override the flow field on copied clients (e.g. "xtls-rprx-vision"). Empty to keep source flow.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/:id/delClient/:clientId',
|
||||||
|
summary: 'Delete a client by its UUID/password from a specific inbound.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||||
|
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/updateClient/:clientId',
|
||||||
|
summary: 'Update a single client without rewriting the whole settings JSON. Send the target inbound payload with the new client values.',
|
||||||
|
params: [
|
||||||
|
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
|
||||||
|
],
|
||||||
|
body:
|
||||||
|
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
|
||||||
|
summary: 'Zero out upload + download counters for one client.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/resetAllTraffics',
|
||||||
|
summary: 'Reset upload + download counters on every inbound. Destructive — accounting history is lost.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/resetAllClientTraffics/:id',
|
||||||
|
summary: 'Reset traffic for every client in one inbound.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/delDepletedClients/:id',
|
||||||
|
summary: 'Delete clients in this inbound whose traffic cap or expiry has elapsed. Pass id=-1 to sweep every inbound.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID, or -1 for all inbounds.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/import',
|
||||||
|
summary: 'Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single "data" field.',
|
||||||
|
params: [
|
||||||
|
{ name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/onlines',
|
||||||
|
summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
|
||||||
|
response: '{\n "success": true,\n "obj": ["user1", "user2"]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/lastOnline',
|
||||||
|
summary: 'Map of client email → last-seen unix timestamp.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/inbounds/getSubLinks/:subId',
|
||||||
|
summary:
|
||||||
|
'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
|
||||||
|
params: [
|
||||||
|
{ name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
|
||||||
|
],
|
||||||
|
response:
|
||||||
|
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#user1",\n "vmess://eyJ2IjoyLC..."\n ]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/inbounds/getClientLinks/:id/:email',
|
||||||
|
summary:
|
||||||
|
"Return the URL(s) for one client on one inbound — the same string 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) return an empty array.",
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||||
|
],
|
||||||
|
response:
|
||||||
|
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?...#user1"\n ]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/updateClientTraffic/:email',
|
||||||
|
summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||||
|
],
|
||||||
|
body: '{\n "upload": 1073741824,\n "download": 5368709120\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/inbounds/:id/delClientByEmail/:email',
|
||||||
|
summary: 'Delete a client identified by email rather than UUID.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'server',
|
||||||
|
title: 'Server API',
|
||||||
|
description:
|
||||||
|
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/status',
|
||||||
|
summary: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/cpuHistory/:bucket',
|
||||||
|
summary: 'Legacy: aggregated CPU history. Use /history/cpu/:bucket instead — same data with a uniform {t, v} shape.',
|
||||||
|
params: [
|
||||||
|
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/history/:metric/:bucket',
|
||||||
|
summary: 'Aggregated time-series for one metric. Returns an array of {t, v} samples covering the last ~6 hours.',
|
||||||
|
params: [
|
||||||
|
{ name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem | swap | netIn | netOut | tcpCount | udpCount | load1 | online.' },
|
||||||
|
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/getXrayVersion',
|
||||||
|
summary: 'List Xray binary versions available for install on this host.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/getPanelUpdateInfo',
|
||||||
|
summary: 'Check whether a newer 3x-ui release is available on GitHub.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/getConfigJson',
|
||||||
|
summary: 'Return the assembled Xray config that’s currently running on this host.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/getDb',
|
||||||
|
summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/getNewUUID',
|
||||||
|
summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/getNewX25519Cert',
|
||||||
|
summary: 'Generate a new X25519 keypair for Reality.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/getNewmldsa65',
|
||||||
|
summary: 'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/getNewmlkem768',
|
||||||
|
summary: 'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/getNewVlessEnc',
|
||||||
|
summary: 'Generate a new VLESS encryption keypair.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/stopXrayService',
|
||||||
|
summary: 'Stop the Xray binary. All proxies go offline immediately.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/restartXrayService',
|
||||||
|
summary: 'Reload Xray with the current config. Typically required after structural inbound or routing changes.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/installXray/:version',
|
||||||
|
summary: 'Download and install the specified Xray version. Pass "latest" for the newest release.',
|
||||||
|
params: [
|
||||||
|
{ name: 'version', in: 'path', type: 'string', desc: 'Xray tag (e.g. v25.10.31) or "latest".' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/updatePanel',
|
||||||
|
summary: 'Self-update the panel to the latest version. The server restarts on success.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/updateGeofile',
|
||||||
|
summary: 'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/updateGeofile/:fileName',
|
||||||
|
summary: 'Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).',
|
||||||
|
params: [
|
||||||
|
{ name: 'fileName', in: 'path', type: 'string', desc: 'Filename of the data file to refresh.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/logs/:count',
|
||||||
|
summary: 'Return the last N lines of the panel’s own log.',
|
||||||
|
params: [
|
||||||
|
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
|
||||||
|
],
|
||||||
|
body: '{\n "level": "info",\n "syslog": false\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/xraylogs/:count',
|
||||||
|
summary: 'Return the last N lines of the Xray process log.',
|
||||||
|
params: [
|
||||||
|
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/importDB',
|
||||||
|
summary: 'Restore the panel DB from an uploaded SQLite file (multipart form, field name "db"). The panel restarts after restore. Destructive.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/getNewEchCert',
|
||||||
|
summary: 'Generate a new ECH (Encrypted Client Hello) keypair. Body picks the algorithm.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'nodes',
|
||||||
|
title: 'Nodes API',
|
||||||
|
description:
|
||||||
|
'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/nodes/list',
|
||||||
|
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/nodes/get/:id',
|
||||||
|
summary: 'Fetch a single node by ID.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/nodes/add',
|
||||||
|
summary: 'Register a new remote node. Provide its URL, apiToken, and optional label/notes.',
|
||||||
|
body:
|
||||||
|
'{\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/nodes/update/:id',
|
||||||
|
summary: 'Replace a node’s connection details. Same body shape as /add.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/nodes/del/:id',
|
||||||
|
summary: 'Delete a node. Inbounds bound to it are not auto-migrated.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/nodes/setEnable/:id',
|
||||||
|
summary: 'Pause or resume traffic sync with this node.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||||
|
],
|
||||||
|
body: '{\n "enable": true\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/nodes/test',
|
||||||
|
summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/nodes/probe/:id',
|
||||||
|
summary: 'Probe an existing node, updating its cached health state.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/nodes/history/:id/:metric/:bucket',
|
||||||
|
summary: 'Aggregated metric history for a node — same shape as /server/history, scoped to one node.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||||
|
{ name: 'metric', in: 'path', type: 'string', desc: 'Metric key (cpu, mem, netIn, …).' },
|
||||||
|
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'customGeo',
|
||||||
|
title: 'Custom Geo API',
|
||||||
|
description:
|
||||||
|
'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/custom-geo/list',
|
||||||
|
summary: 'List configured custom geo sources with their type, alias, URL, status, and last-download timestamp.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/custom-geo/aliases',
|
||||||
|
summary: 'List geo aliases currently usable in routing rules — both built-in defaults and the user-configured ones.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/custom-geo/add',
|
||||||
|
summary: 'Register a custom geo source. Alias is auto-normalised; URL must point to a .dat / .json blob.',
|
||||||
|
body:
|
||||||
|
'{\n "type": "geoip",\n "alias": "myips",\n "url": "https://example.com/geo/my.dat"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/custom-geo/update/:id',
|
||||||
|
summary: 'Replace a custom geo source. Same body shape as /add.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/custom-geo/delete/:id',
|
||||||
|
summary: 'Remove a custom geo source and its cached file.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/custom-geo/download/:id',
|
||||||
|
summary: 'Re-download one custom geo source on demand.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/custom-geo/update-all',
|
||||||
|
summary: 'Re-download every configured custom geo source. Errors are reported per-source in the response.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'backup',
|
||||||
|
title: 'Backup',
|
||||||
|
description: 'Operations that interact with the configured Telegram bot.',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/backuptotgbot',
|
||||||
|
summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const methodColors = {
|
||||||
|
GET: 'blue',
|
||||||
|
POST: 'green',
|
||||||
|
PUT: 'orange',
|
||||||
|
PATCH: 'orange',
|
||||||
|
DELETE: 'red',
|
||||||
|
};
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { SyncOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
import {
|
||||||
|
Inbound,
|
||||||
|
Protocols,
|
||||||
|
USERS_SECURITY,
|
||||||
|
TLS_FLOW_CONTROL,
|
||||||
|
} from '@/models/inbound.js';
|
||||||
|
import DateTimePicker from '@/components/DateTimePicker.vue';
|
||||||
|
|
||||||
|
// Bulk-add up to 500 clients in one go. The legacy panel offers five
|
||||||
|
// generation modes — this component preserves them all:
|
||||||
|
// 0: Random — N fully-random emails (no prefix)
|
||||||
|
// 1: Random+Prefix — N random emails preceded by `prefix`
|
||||||
|
// 2: Random+Prefix+Num — emails like `<rand><prefix><num>` for num in [first..last]
|
||||||
|
// 3: Random+Prefix+Num+Postfix — same + appended postfix
|
||||||
|
// 4: Prefix+Num+Postfix — no random part, just `<prefix><num><postfix>`
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
dbInbound: { type: Object, default: null },
|
||||||
|
subEnable: { type: Boolean, default: false },
|
||||||
|
tgBotEnable: { type: Boolean, default: false },
|
||||||
|
ipLimitEnable: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'saved']);
|
||||||
|
|
||||||
|
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
|
||||||
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
|
|
||||||
|
// === Reactive form state ===========================================
|
||||||
|
// Cloned inbound (so canEnableTlsFlow() works).
|
||||||
|
const inbound = ref(null);
|
||||||
|
const saving = ref(false);
|
||||||
|
const delayedStart = ref(false);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
emailMethod: 0,
|
||||||
|
firstNum: 1,
|
||||||
|
lastNum: 1,
|
||||||
|
emailPrefix: '',
|
||||||
|
emailPostfix: '',
|
||||||
|
quantity: 1,
|
||||||
|
security: USERS_SECURITY.AUTO,
|
||||||
|
flow: '',
|
||||||
|
subId: '',
|
||||||
|
tgId: 0,
|
||||||
|
comment: '',
|
||||||
|
limitIp: 0,
|
||||||
|
totalGB: 0,
|
||||||
|
expiryTime: 0, // ms epoch; negative => delayed start days
|
||||||
|
reset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiryDate = computed({
|
||||||
|
get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
|
||||||
|
set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delayedExpireDays = computed({
|
||||||
|
get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
|
||||||
|
set: (days) => { form.expiryTime = -86400000 * (days || 0); },
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (!next) return;
|
||||||
|
if (!props.dbInbound) return;
|
||||||
|
inbound.value = Inbound.fromJson(props.dbInbound.toInbound().toJson());
|
||||||
|
// Reset all form fields on every open — bulk add is intentionally
|
||||||
|
// stateless between sessions (legacy resets on .show()).
|
||||||
|
form.emailMethod = 0;
|
||||||
|
form.firstNum = 1;
|
||||||
|
form.lastNum = 1;
|
||||||
|
form.emailPrefix = '';
|
||||||
|
form.emailPostfix = '';
|
||||||
|
form.quantity = 1;
|
||||||
|
form.security = USERS_SECURITY.AUTO;
|
||||||
|
form.flow = '';
|
||||||
|
form.subId = '';
|
||||||
|
form.tgId = 0;
|
||||||
|
form.comment = '';
|
||||||
|
form.limitIp = 0;
|
||||||
|
form.totalGB = 0;
|
||||||
|
form.expiryTime = 0;
|
||||||
|
form.reset = 0;
|
||||||
|
delayedStart.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNewClient(parsed) {
|
||||||
|
switch (parsed.protocol) {
|
||||||
|
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
|
||||||
|
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
||||||
|
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
||||||
|
case Protocols.SHADOWSOCKS: {
|
||||||
|
const method = parsed.settings.shadowsockses[0]?.method || parsed.settings.method;
|
||||||
|
return new Inbound.ShadowsocksSettings.Shadowsocks(method);
|
||||||
|
}
|
||||||
|
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClients() {
|
||||||
|
if (!inbound.value) return [];
|
||||||
|
const out = [];
|
||||||
|
const method = form.emailMethod;
|
||||||
|
let start;
|
||||||
|
let end;
|
||||||
|
if (method > 1) {
|
||||||
|
start = form.firstNum;
|
||||||
|
end = form.lastNum + 1;
|
||||||
|
} else {
|
||||||
|
start = 0;
|
||||||
|
end = form.quantity;
|
||||||
|
}
|
||||||
|
const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
|
||||||
|
const useNum = method > 1;
|
||||||
|
const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
|
||||||
|
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
const c = makeNewClient(inbound.value);
|
||||||
|
if (!c) continue;
|
||||||
|
if (method === 4) c.email = '';
|
||||||
|
c.email += useNum ? prefix + String(i) + postfix : prefix + postfix;
|
||||||
|
|
||||||
|
if (form.subId.length > 0) c.subId = form.subId;
|
||||||
|
c.tgId = form.tgId;
|
||||||
|
if (form.comment.length > 0) c.comment = form.comment;
|
||||||
|
c.security = form.security;
|
||||||
|
c.limitIp = form.limitIp;
|
||||||
|
// Use the clien's totalGB setter (ms epoch and bytes already handled
|
||||||
|
// identically for bulk and single client paths).
|
||||||
|
c.totalGB = Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB);
|
||||||
|
c.expiryTime = form.expiryTime;
|
||||||
|
if (inbound.value.canEnableTlsFlow()) c.flow = form.flow;
|
||||||
|
c.reset = form.reset;
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const clients = buildClients();
|
||||||
|
if (clients.length === 0) return;
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
id: props.dbInbound.id,
|
||||||
|
// Clients all serialize via toString() — same shape the single-
|
||||||
|
// client modal posts. Joining with `,` lets the Go side parse the
|
||||||
|
// outer array directly.
|
||||||
|
settings: `{"clients": [${clients.map((c) => c.toString()).join(',')}]}`,
|
||||||
|
};
|
||||||
|
const msg = await HttpUtil.post('/panel/api/inbounds/addClient', payload);
|
||||||
|
if (msg?.success) {
|
||||||
|
emit('saved');
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="t('pages.client.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
|
||||||
|
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
|
||||||
|
<a-form v-if="inbound" :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
||||||
|
<a-form-item :label="t('pages.client.method')">
|
||||||
|
<a-select v-model:value="form.emailMethod">
|
||||||
|
<a-select-option :value="0">Random</a-select-option>
|
||||||
|
<a-select-option :value="1">Random + Prefix</a-select-option>
|
||||||
|
<a-select-option :value="2">Random + Prefix + Num</a-select-option>
|
||||||
|
<a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
|
||||||
|
<a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.first')">
|
||||||
|
<a-input-number v-model:value="form.firstNum" :min="1" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.last')">
|
||||||
|
<a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="form.emailMethod > 0" :label="t('pages.client.prefix')">
|
||||||
|
<a-input v-model:value="form.emailPrefix" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="form.emailMethod > 2" :label="t('pages.client.postfix')">
|
||||||
|
<a-input v-model:value="form.emailPostfix" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="form.emailMethod < 2" :label="t('pages.client.clientCount')">
|
||||||
|
<a-input-number v-model:value="form.quantity" :min="1" :max="500" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="inbound.protocol === Protocols.VMESS" :label="t('security')">
|
||||||
|
<a-select v-model:value="form.security">
|
||||||
|
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
||||||
|
<a-select v-model:value="form.flow">
|
||||||
|
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||||
|
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="subEnable">
|
||||||
|
<template #label>
|
||||||
|
{{ t('subscription.title') }}
|
||||||
|
<SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="form.subId" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="tgBotEnable" label="Telegram ID">
|
||||||
|
<a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item :label="t('comment')">
|
||||||
|
<a-input v-model:value="form.comment" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
|
||||||
|
<a-input-number v-model:value="form.limitIp" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item :label="t('pages.client.delayedStart')">
|
||||||
|
<a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
|
||||||
|
<a-input-number v-model:value="delayedExpireDays" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-else>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
|
||||||
|
}}</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<DateTimePicker v-model:value="expiryDate" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="form.expiryTime !== 0">
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input-number v-model:value="form.reset" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.random-icon {
|
||||||
|
margin-left: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ant-primary-color, #1890ff);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { SyncOutlined, RetweetOutlined, DeleteOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
HttpUtil,
|
||||||
|
RandomUtil,
|
||||||
|
SizeFormatter,
|
||||||
|
ColorUtils,
|
||||||
|
} from '@/utils';
|
||||||
|
import { Inbound, Protocols, USERS_SECURITY, TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
||||||
|
import DateTimePicker from '@/components/DateTimePicker.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// Add OR edit a single client on a multi-user inbound (VMess / VLess /
|
||||||
|
// Trojan / Shadowsocks-multi / Hysteria). The legacy panel routes both
|
||||||
|
// flows through the same modal — same here.
|
||||||
|
//
|
||||||
|
// On submit we serialize the client via its toString() (which is just
|
||||||
|
// JSON.stringify of toJson()) and post it inside a one-element clients
|
||||||
|
// array so the Go side reuses the same parsing path as the inbound
|
||||||
|
// settings update.
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
|
||||||
|
dbInbound: { type: Object, default: null },
|
||||||
|
clientIndex: { type: Number, default: null },
|
||||||
|
// Sidecar config from the inbounds page — controls visibility of
|
||||||
|
// the Subscription, Telegram, and IP-limit fields.
|
||||||
|
subEnable: { type: Boolean, default: false },
|
||||||
|
tgBotEnable: { type: Boolean, default: false },
|
||||||
|
ipLimitEnable: { type: Boolean, default: false },
|
||||||
|
trafficDiff: { type: Number, default: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'saved']);
|
||||||
|
|
||||||
|
// === Reactive draft =================================================
|
||||||
|
const inbound = ref(null);
|
||||||
|
const client = ref(null);
|
||||||
|
const oldClientId = ref('');
|
||||||
|
const clientStats = ref(null);
|
||||||
|
|
||||||
|
const saving = ref(false);
|
||||||
|
const delayedStart = ref(false);
|
||||||
|
|
||||||
|
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
|
||||||
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
|
|
||||||
|
const protocol = computed(() => inbound.value?.protocol);
|
||||||
|
const isVmessOrVless = computed(() =>
|
||||||
|
protocol.value === Protocols.VMESS || protocol.value === Protocols.VLESS,
|
||||||
|
);
|
||||||
|
const isTrojanOrSS = computed(() =>
|
||||||
|
protocol.value === Protocols.TROJAN || protocol.value === Protocols.SHADOWSOCKS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const expiryDate = computed({
|
||||||
|
get: () => (client.value?.expiryTime > 0 ? dayjs(client.value.expiryTime) : null),
|
||||||
|
set: (next) => { if (client.value) client.value.expiryTime = next ? next.valueOf() : 0; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delayedExpireDays = computed({
|
||||||
|
get: () => {
|
||||||
|
if (!client.value || client.value.expiryTime >= 0) return 0;
|
||||||
|
return client.value.expiryTime / -86400000;
|
||||||
|
},
|
||||||
|
set: (days) => {
|
||||||
|
if (!client.value) return;
|
||||||
|
client.value.expiryTime = -86400000 * (days || 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalGB = computed({
|
||||||
|
get: () => {
|
||||||
|
if (!client.value || !client.value.totalGB) return 0;
|
||||||
|
return Math.round((client.value.totalGB / SizeFormatter.ONE_GB) * 100) / 100;
|
||||||
|
},
|
||||||
|
set: (gb) => {
|
||||||
|
if (!client.value) return;
|
||||||
|
client.value.totalGB = Math.round((gb || 0) * SizeFormatter.ONE_GB);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isExpired = computed(() => {
|
||||||
|
if (props.mode !== 'edit' || !client.value) return false;
|
||||||
|
return client.value.expiryTime > 0 && client.value.expiryTime < Date.now();
|
||||||
|
});
|
||||||
|
const isTrafficExhausted = computed(() => {
|
||||||
|
if (!clientStats.value || clientStats.value.total <= 0) return false;
|
||||||
|
return clientStats.value.up + clientStats.value.down >= clientStats.value.total;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getClientId(proto, c) {
|
||||||
|
switch (proto) {
|
||||||
|
case Protocols.TROJAN: return c.password;
|
||||||
|
case Protocols.SHADOWSOCKS: return c.email;
|
||||||
|
case Protocols.HYSTERIA: return c.auth;
|
||||||
|
default: return c.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNewClient(proto, parsed) {
|
||||||
|
switch (proto) {
|
||||||
|
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
|
||||||
|
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
||||||
|
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
||||||
|
case Protocols.SHADOWSOCKS: {
|
||||||
|
const method = parsed.settings.method;
|
||||||
|
return new Inbound.ShadowsocksSettings.Shadowsocks(
|
||||||
|
method,
|
||||||
|
RandomUtil.randomShadowsocksPassword(method),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (!next) return;
|
||||||
|
if (!props.dbInbound) return;
|
||||||
|
const parsed = Inbound.fromJson(props.dbInbound.toInbound().toJson());
|
||||||
|
inbound.value = parsed;
|
||||||
|
delayedStart.value = false;
|
||||||
|
|
||||||
|
if (props.mode === 'edit') {
|
||||||
|
const idx = props.clientIndex ?? 0;
|
||||||
|
client.value = parsed.clients[idx];
|
||||||
|
if (client.value && client.value.expiryTime < 0) delayedStart.value = true;
|
||||||
|
oldClientId.value = getClientId(parsed.protocol, client.value);
|
||||||
|
} else {
|
||||||
|
const c = makeNewClient(parsed.protocol, parsed);
|
||||||
|
if (c) parsed.clients.push(c);
|
||||||
|
client.value = parsed.clients[parsed.clients.length - 1];
|
||||||
|
oldClientId.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
clientStats.value = (props.dbInbound.clientStats || []).find(
|
||||||
|
(s) => s.email === client.value?.email,
|
||||||
|
) || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomEmail() {
|
||||||
|
if (client.value) client.value.email = RandomUtil.randomLowerAndNum(9);
|
||||||
|
}
|
||||||
|
function randomId() {
|
||||||
|
if (client.value) client.value.id = RandomUtil.randomUUID();
|
||||||
|
}
|
||||||
|
function randomPassword() {
|
||||||
|
if (!client.value || !inbound.value) return;
|
||||||
|
if (inbound.value.protocol === Protocols.SHADOWSOCKS) {
|
||||||
|
client.value.password = RandomUtil.randomShadowsocksPassword(
|
||||||
|
inbound.value.settings.method,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
client.value.password = RandomUtil.randomSeq(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function randomAuth() {
|
||||||
|
if (client.value) client.value.auth = RandomUtil.randomSeq(10);
|
||||||
|
}
|
||||||
|
function randomSubId() {
|
||||||
|
if (client.value) client.value.subId = RandomUtil.randomLowerAndNum(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIpsText = ref('');
|
||||||
|
async function loadClientIps() {
|
||||||
|
if (!client.value?.email) return;
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${client.value.email}`);
|
||||||
|
if (!msg?.success) {
|
||||||
|
clientIpsText.value = msg?.obj || '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ips = msg.obj;
|
||||||
|
if (typeof ips === 'string' && ips.startsWith('[') && ips.endsWith(']')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(ips);
|
||||||
|
ips = Array.isArray(parsed) ? parsed.join('\n') : ips;
|
||||||
|
} catch (_e) {
|
||||||
|
// leave as raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clientIpsText.value = ips || '';
|
||||||
|
}
|
||||||
|
async function clearClientIps() {
|
||||||
|
if (!client.value?.email) return;
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${client.value.email}`);
|
||||||
|
if (msg?.success) clientIpsText.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetClientTraffic() {
|
||||||
|
if (!clientStats.value || !client.value?.email) return;
|
||||||
|
const msg = await HttpUtil.post(
|
||||||
|
`/panel/api/inbounds/${props.dbInbound.id}/resetClientTraffic/${client.value.email}`,
|
||||||
|
);
|
||||||
|
if (msg?.success) {
|
||||||
|
clientStats.value.up = 0;
|
||||||
|
clientStats.value.down = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!client.value || !inbound.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
id: props.dbInbound.id,
|
||||||
|
settings: `{"clients": [${client.value.toString()}]}`,
|
||||||
|
};
|
||||||
|
const url = props.mode === 'edit'
|
||||||
|
? `/panel/api/inbounds/updateClient/${oldClientId.value}`
|
||||||
|
: '/panel/api/inbounds/addClient';
|
||||||
|
const msg = await HttpUtil.post(url, payload);
|
||||||
|
if (msg?.success) {
|
||||||
|
emit('saved');
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = computed(() =>
|
||||||
|
props.mode === 'edit' ? t('pages.client.edit') : t('pages.client.add'),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="title"
|
||||||
|
:ok-text="mode === 'edit' ? t('pages.client.submitEdit') : t('pages.client.submitAdd')" :cancel-text="t('close')"
|
||||||
|
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
|
||||||
|
<a-tag v-if="mode === 'edit' && (isExpired || isTrafficExhausted)" color="red" class="status-banner">
|
||||||
|
{{ t('depleted') }}
|
||||||
|
</a-tag>
|
||||||
|
|
||||||
|
<a-form v-if="client && inbound" layout="horizontal" :colon="false" :label-col="{ sm: { span: 8 } }"
|
||||||
|
:wrapper-col="{ sm: { span: 14 } }">
|
||||||
|
<a-form-item :label="t('enable')">
|
||||||
|
<a-switch v-model:checked="client.enable" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
{{ t('pages.inbounds.email') }}
|
||||||
|
<SyncOutlined class="random-icon" @click="randomEmail" />
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="client.email" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="isTrojanOrSS">
|
||||||
|
<template #label>
|
||||||
|
{{ t('password') }}
|
||||||
|
<SyncOutlined class="random-icon" @click="randomPassword" />
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="client.password" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="protocol === Protocols.HYSTERIA">
|
||||||
|
<template #label>
|
||||||
|
{{ t('password') }}
|
||||||
|
<SyncOutlined class="random-icon" @click="randomAuth" />
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="client.auth" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="isVmessOrVless">
|
||||||
|
<template #label>
|
||||||
|
ID
|
||||||
|
<SyncOutlined class="random-icon" @click="randomId" />
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="client.id" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="protocol === Protocols.VMESS" :label="t('security')">
|
||||||
|
<a-select v-model:value="client.security">
|
||||||
|
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">
|
||||||
|
{{ key }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="client.email && subEnable">
|
||||||
|
<template #label>
|
||||||
|
{{ t('subscription.title') }}
|
||||||
|
<SyncOutlined class="random-icon" @click="randomSubId" />
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="client.subId" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="client.email && tgBotEnable" label="Telegram ID">
|
||||||
|
<a-input-number v-model:value="client.tgId" :min="0" :style="{ width: '50%' }" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="client.email" :label="t('comment')">
|
||||||
|
<a-input v-model:value="client.comment" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
|
||||||
|
<a-input-number v-model:value="client.limitIp" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="ipLimitEnable && client.limitIp > 0 && client.email && mode === 'edit'"
|
||||||
|
:label="t('pages.inbounds.IPLimitlog')">
|
||||||
|
<a-textarea v-model:value="clientIpsText" readonly :placeholder="t('pages.inbounds.IPLimitlogDesc')"
|
||||||
|
:auto-size="{ minRows: 3, maxRows: 8 }" @click="loadClientIps" />
|
||||||
|
<a-button type="link" size="small" danger @click="clearClientIps">
|
||||||
|
<template #icon>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</template>
|
||||||
|
{{ t('pages.inbounds.IPLimitlogclear') }}
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
||||||
|
<a-select v-model:value="client.flow">
|
||||||
|
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||||
|
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">
|
||||||
|
{{ key }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="protocol === Protocols.VLESS" label="Reverse tag">
|
||||||
|
<a-input v-model:value="client.reverseTag" placeholder="Optional reverse tag" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="mode === 'edit' && clientStats" :label="t('usage')">
|
||||||
|
<a-tag :color="ColorUtils.clientUsageColor(clientStats, trafficDiff)">
|
||||||
|
{{ SizeFormatter.sizeFormat(clientStats.up) }} /
|
||||||
|
{{ SizeFormatter.sizeFormat(clientStats.down) }}
|
||||||
|
({{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }})
|
||||||
|
</a-tag>
|
||||||
|
<a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
|
||||||
|
<RetweetOutlined class="action-icon" @click="resetClientTraffic" />
|
||||||
|
</a-tooltip>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item :label="t('pages.client.delayedStart')">
|
||||||
|
<a-switch v-model:checked="delayedStart" @click="client.expiryTime = 0" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
|
||||||
|
<a-input-number v-model:value="delayedExpireDays" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-else>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
|
||||||
|
}}</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<DateTimePicker v-model:value="expiryDate" />
|
||||||
|
<a-tag v-if="mode === 'edit' && isExpired" color="red">{{ t('depleted') }}</a-tag>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="client.expiryTime !== 0">
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input-number v-model:value="client.reset" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.status-banner {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.random-icon,
|
||||||
|
.action-icon {
|
||||||
|
margin-left: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ant-primary-color, #1890ff);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,821 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
QrcodeOutlined,
|
||||||
|
RetweetOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EllipsisOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||||
|
import InfinityIcon from '@/components/InfinityIcon.vue';
|
||||||
|
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||||
|
|
||||||
|
const { datepicker } = useDatepicker();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// Per-inbound expand-row content. CSS-grid layout (not a nested
|
||||||
|
// <a-table>) so it sits flush inside the parent's expanded cell.
|
||||||
|
// No API calls here — events bubble to the parent's modals.
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
dbInbound: { type: Object, required: true },
|
||||||
|
isMobile: { type: Boolean, default: false },
|
||||||
|
trafficDiff: { type: Number, default: 0 },
|
||||||
|
expireDiff: { type: Number, default: 0 },
|
||||||
|
onlineClients: { type: Array, default: () => [] },
|
||||||
|
lastOnlineMap: { type: Object, default: () => ({}) },
|
||||||
|
isDarkTheme: { type: Boolean, default: false },
|
||||||
|
pageSize: { type: Number, default: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'edit-client',
|
||||||
|
'qrcode-client',
|
||||||
|
'info-client',
|
||||||
|
'reset-traffic-client',
|
||||||
|
'delete-client',
|
||||||
|
'delete-clients',
|
||||||
|
'toggle-enable-client',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inbound = computed(() => props.dbInbound.toInbound());
|
||||||
|
const clients = computed(() => inbound.value?.clients || []);
|
||||||
|
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const paginatedClients = computed(() => {
|
||||||
|
if (!props.pageSize || props.pageSize <= 0) return clients.value;
|
||||||
|
const start = (currentPage.value - 1) * props.pageSize;
|
||||||
|
return clients.value.slice(start, start + props.pageSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([clients, () => props.pageSize], () => {
|
||||||
|
const total = clients.value.length;
|
||||||
|
const size = props.pageSize > 0 ? props.pageSize : (total || 1);
|
||||||
|
const maxPage = Math.max(1, Math.ceil(total / size));
|
||||||
|
if (currentPage.value > maxPage) currentPage.value = maxPage;
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Per-client stats lookup =======================================
|
||||||
|
const statsMap = computed(() => {
|
||||||
|
const m = new Map();
|
||||||
|
for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
function statsFor(email) {
|
||||||
|
return email ? statsMap.value.get(email) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUp(email) { return statsFor(email)?.up || 0; }
|
||||||
|
function getDown(email) { return statsFor(email)?.down || 0; }
|
||||||
|
function getSum(email) { const s = statsFor(email); return s ? s.up + s.down : 0; }
|
||||||
|
function getRem(email) {
|
||||||
|
const s = statsFor(email);
|
||||||
|
if (!s) return 0;
|
||||||
|
const r = s.total - s.up - s.down;
|
||||||
|
return r > 0 ? r : 0;
|
||||||
|
}
|
||||||
|
function getAllTime(email) {
|
||||||
|
const s = statsFor(email);
|
||||||
|
if (!s) return 0;
|
||||||
|
// allTime is the cumulative-historical counter; never let it dip
|
||||||
|
// below up+down (manual edits / partial migrations can push it under).
|
||||||
|
const current = (s.up || 0) + (s.down || 0);
|
||||||
|
return s.allTime > current ? s.allTime : current;
|
||||||
|
}
|
||||||
|
function isClientDepleted(email) {
|
||||||
|
const s = statsFor(email);
|
||||||
|
if (!s) return false;
|
||||||
|
const total = s.total ?? 0;
|
||||||
|
const used = (s.up ?? 0) + (s.down ?? 0);
|
||||||
|
if (total > 0 && used >= total) return true;
|
||||||
|
const exp = s.expiryTime ?? 0;
|
||||||
|
if (exp > 0 && Date.now() >= exp) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function isClientOnline(email) {
|
||||||
|
return !!email && props.onlineClients.includes(email);
|
||||||
|
}
|
||||||
|
function lastOnlineLabel(email) {
|
||||||
|
const ts = props.lastOnlineMap[email];
|
||||||
|
if (!ts) return '-';
|
||||||
|
return IntlUtil.formatDate(ts, datepicker.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statsProgress(email) {
|
||||||
|
const s = statsFor(email);
|
||||||
|
if (!s) return 0;
|
||||||
|
if (s.total === 0) return 100;
|
||||||
|
return (100 * (s.down + s.up)) / s.total;
|
||||||
|
}
|
||||||
|
function expireProgress(expTime, reset) {
|
||||||
|
const now = Date.now();
|
||||||
|
const remainedSec = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
|
||||||
|
const resetSec = reset * 86400;
|
||||||
|
if (remainedSec >= resetSec) return 0;
|
||||||
|
return 100 * (1 - remainedSec / resetSec);
|
||||||
|
}
|
||||||
|
function clientStatsColor(email) {
|
||||||
|
return ColorUtils.clientUsageColor(statsFor(email), props.trafficDiff);
|
||||||
|
}
|
||||||
|
function statsExpColor(email) {
|
||||||
|
// AD-Vue 4 semantic palette mirrors ColorUtils.* so the badge dot
|
||||||
|
// matches the row's traffic/expiry tags.
|
||||||
|
const PURPLE = '#722ed1', SUCCESS = '#52c41a', WARN = '#faad14', DANGER = '#ff4d4f';
|
||||||
|
if (!email) return PURPLE;
|
||||||
|
const s = statsFor(email);
|
||||||
|
if (!s) return PURPLE;
|
||||||
|
const a = ColorUtils.usageColor(s.down + s.up, props.trafficDiff, s.total);
|
||||||
|
const b = ColorUtils.usageColor(Date.now(), props.expireDiff, s.expiryTime);
|
||||||
|
if (a === 'red' || b === 'red') return DANGER;
|
||||||
|
if (a === 'orange' || b === 'orange') return WARN;
|
||||||
|
if (a === 'green' || b === 'green') return SUCCESS;
|
||||||
|
return PURPLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRemovable = computed(() => clients.value.length > 1);
|
||||||
|
|
||||||
|
function totalGbDisplay(client) {
|
||||||
|
if (!client.totalGB || client.totalGB <= 0) return '';
|
||||||
|
return `${Math.round((client.totalGB / 1073741824) * 100) / 100} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUnlimitedTotal = (client) => !client.totalGB || client.totalGB <= 0;
|
||||||
|
|
||||||
|
function statusBadgeColor(client) {
|
||||||
|
if (!client.enable) return props.isDarkTheme ? '#2c3950' : '#bcbcbc';
|
||||||
|
return statsExpColor(client.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Action confirms ==============================================
|
||||||
|
function confirmReset(client) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `${t('pages.inbounds.resetTraffic')} — ${client.email}`,
|
||||||
|
content: t('pages.inbounds.resetTrafficContent'),
|
||||||
|
okText: t('reset'),
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: () => emit('reset-traffic-client', { dbInbound: props.dbInbound, client }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function confirmDelete(client) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `${t('pages.inbounds.deleteClient')} — ${client.email}`,
|
||||||
|
content: t('pages.inbounds.deleteClientContent'),
|
||||||
|
okText: t('delete'),
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: () => emit('delete-client', { dbInbound: props.dbInbound, client }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable row key for v-for — falls back through email/id/password
|
||||||
|
// because not every protocol fills the same field.
|
||||||
|
function rowKey(client) {
|
||||||
|
return client.email || client.id || client.password || JSON.stringify(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = ref(new Set());
|
||||||
|
|
||||||
|
const allSelected = computed(() =>
|
||||||
|
clients.value.length > 0 && clients.value.every((c) => selected.value.has(rowKey(c))),
|
||||||
|
);
|
||||||
|
const someSelected = computed(() =>
|
||||||
|
clients.value.some((c) => selected.value.has(rowKey(c))),
|
||||||
|
);
|
||||||
|
const selectedCount = computed(() => selected.value.size);
|
||||||
|
|
||||||
|
function isSelected(key) {
|
||||||
|
return selected.value.has(key);
|
||||||
|
}
|
||||||
|
function toggleSelect(key, next) {
|
||||||
|
const s = new Set(selected.value);
|
||||||
|
if (next) s.add(key); else s.delete(key);
|
||||||
|
selected.value = s;
|
||||||
|
}
|
||||||
|
function selectAll(next) {
|
||||||
|
if (next) {
|
||||||
|
selected.value = new Set(clients.value.map(rowKey));
|
||||||
|
} else {
|
||||||
|
selected.value = new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function clearSelection() {
|
||||||
|
selected.value = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(clients, (list) => {
|
||||||
|
if (selected.value.size === 0) return;
|
||||||
|
const valid = new Set(list.map(rowKey));
|
||||||
|
const next = new Set();
|
||||||
|
for (const k of selected.value) if (valid.has(k)) next.add(k);
|
||||||
|
if (next.size !== selected.value.size) selected.value = next;
|
||||||
|
});
|
||||||
|
|
||||||
|
function confirmBulkDelete() {
|
||||||
|
const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
|
||||||
|
if (picked.length === 0) return;
|
||||||
|
|
||||||
|
const total = clients.value.length;
|
||||||
|
const keepLast = picked.length === total;
|
||||||
|
const toDelete = keepLast ? picked.slice(0, -1) : picked;
|
||||||
|
|
||||||
|
if (toDelete.length === 0) {
|
||||||
|
Modal.warning({
|
||||||
|
title: t('pages.inbounds.deleteClient'),
|
||||||
|
content: 'Inbound must keep at least one client — delete the inbound to remove all.',
|
||||||
|
okText: t('confirm'),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: `${t('pages.inbounds.deleteClient')} — ${toDelete.length}${keepLast ? ` / ${total}` : ''}`,
|
||||||
|
content: keepLast
|
||||||
|
? 'Inbound must keep at least one client — the last selected will remain. Delete the inbound to remove all.'
|
||||||
|
: t('pages.inbounds.deleteClientContent'),
|
||||||
|
okText: t('delete'),
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: () => {
|
||||||
|
emit('delete-clients', { dbInbound: props.dbInbound, clients: toDelete });
|
||||||
|
clearSelection();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="client-list"
|
||||||
|
:class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
|
||||||
|
<div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
|
||||||
|
<span class="bulk-count">{{ selectedCount }} selected</span>
|
||||||
|
<a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
|
||||||
|
<a-button size="small" danger @click="confirmBulkDelete">
|
||||||
|
<DeleteOutlined /> {{ t('delete') }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================== Desktop: grid table ===================== -->
|
||||||
|
<template v-if="!isMobile">
|
||||||
|
<div class="client-row client-list-header">
|
||||||
|
<div v-if="isRemovable" class="cell cell-select">
|
||||||
|
<a-checkbox :checked="allSelected" :indeterminate="someSelected && !allSelected"
|
||||||
|
@change="(e) => selectAll(e.target.checked)" />
|
||||||
|
</div>
|
||||||
|
<div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
|
||||||
|
<div class="cell cell-enable">{{ t('enable') }}</div>
|
||||||
|
<div class="cell cell-online">{{ t('online') }}</div>
|
||||||
|
<div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
|
||||||
|
<div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
|
||||||
|
<div class="cell cell-remained">{{ t('remained') }}</div>
|
||||||
|
<div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
|
||||||
|
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-row"
|
||||||
|
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
||||||
|
<div v-if="isRemovable" class="cell cell-select">
|
||||||
|
<a-checkbox :checked="isSelected(rowKey(client))"
|
||||||
|
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
||||||
|
</div>
|
||||||
|
<div class="cell cell-actions">
|
||||||
|
<a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
|
||||||
|
<QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :title="t('edit')">
|
||||||
|
<EditOutlined class="row-icon" @click="emit('edit-client', { dbInbound, client })" />
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :title="t('info')">
|
||||||
|
<InfoCircleOutlined class="row-icon" @click="emit('info-client', { dbInbound, client })" />
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
|
||||||
|
<RetweetOutlined class="row-icon" @click="confirmReset(client)" />
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip v-if="isRemovable" :title="t('delete')">
|
||||||
|
<DeleteOutlined class="row-icon danger" @click="confirmDelete(client)" />
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cell cell-enable">
|
||||||
|
<a-switch :checked="client.enable" size="small"
|
||||||
|
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cell cell-online">
|
||||||
|
<a-popover>
|
||||||
|
<template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
|
||||||
|
<a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
|
||||||
|
<a-tag v-else>{{ t('offline') }}</a-tag>
|
||||||
|
</a-popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cell cell-client">
|
||||||
|
<a-tooltip>
|
||||||
|
<template #title>
|
||||||
|
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
||||||
|
<template v-else-if="!client.enable">{{ t('disabled') }}</template>
|
||||||
|
<template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
|
||||||
|
<template v-else>{{ t('offline') }}</template>
|
||||||
|
</template>
|
||||||
|
<a-badge :color="statusBadgeColor(client)" />
|
||||||
|
</a-tooltip>
|
||||||
|
<div class="client-id-stack">
|
||||||
|
<a-tooltip :title="client.email">
|
||||||
|
<span class="client-email">{{ client.email }}</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="client.comment && client.comment.trim()" class="client-comment">
|
||||||
|
{{ client.comment.length > 50 ? client.comment.substring(0, 47) + '…' : client.comment }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cell cell-traffic">
|
||||||
|
<a-popover>
|
||||||
|
<template v-if="client.email" #content>
|
||||||
|
<table cellpadding="2">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>↑ {{ SizeFormatter.sizeFormat(getUp(client.email)) }}</td>
|
||||||
|
<td>↓ {{ SizeFormatter.sizeFormat(getDown(client.email)) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="client.totalGB > 0">
|
||||||
|
<td>{{ t('remained') }}</td>
|
||||||
|
<td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
<div class="usage-bar">
|
||||||
|
<span class="usage-text">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</span>
|
||||||
|
<a-progress v-if="!client.enable" :stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
|
||||||
|
:show-info="false" :percent="statsProgress(client.email)" size="small" />
|
||||||
|
<a-progress v-else-if="client.totalGB > 0" :stroke-color="clientStatsColor(client.email)"
|
||||||
|
:show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
|
||||||
|
:percent="statsProgress(client.email)" size="small" />
|
||||||
|
<a-progress v-else :show-info="false" :percent="100" stroke-color="#722ed1" size="small" />
|
||||||
|
<span class="usage-text">
|
||||||
|
<InfinityIcon v-if="isUnlimitedTotal(client)" />
|
||||||
|
<template v-else>{{ totalGbDisplay(client) }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a-popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cell cell-remained">
|
||||||
|
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
||||||
|
<InfinityIcon />
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
|
||||||
|
{{ SizeFormatter.sizeFormat(getRem(client.email)) }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cell cell-alltime">
|
||||||
|
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cell cell-expiry">
|
||||||
|
<template v-if="client.expiryTime !== 0 && client.reset > 0">
|
||||||
|
<a-popover>
|
||||||
|
<template #content>
|
||||||
|
<span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
|
||||||
|
<span v-else>{{ IntlUtil.formatDate(client.expiryTime, datepicker) }}</span>
|
||||||
|
</template>
|
||||||
|
<div class="usage-bar">
|
||||||
|
<span class="usage-text">{{ IntlUtil.formatRelativeTime(client.expiryTime) }}</span>
|
||||||
|
<a-progress :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
|
||||||
|
:percent="expireProgress(client.expiryTime, client.reset)" size="small" />
|
||||||
|
<span class="usage-text">{{ client.reset }}d</span>
|
||||||
|
</div>
|
||||||
|
</a-popover>
|
||||||
|
</template>
|
||||||
|
<a-popover v-else-if="client.expiryTime !== 0">
|
||||||
|
<template #content>
|
||||||
|
<span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
|
||||||
|
<span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
|
||||||
|
</template>
|
||||||
|
<a-tag :style="{ minWidth: '50px', border: 'none' }"
|
||||||
|
:color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
|
||||||
|
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
|
||||||
|
</a-tag>
|
||||||
|
</a-popover>
|
||||||
|
<a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)" :style="{ border: 'none' }"
|
||||||
|
class="infinite-tag">
|
||||||
|
<InfinityIcon />
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ====================== Mobile: card list ======================= -->
|
||||||
|
<template v-else>
|
||||||
|
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-card"
|
||||||
|
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
||||||
|
<div class="client-card-head">
|
||||||
|
<a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
|
||||||
|
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
||||||
|
<a-tooltip>
|
||||||
|
<template #title>
|
||||||
|
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
||||||
|
<template v-else-if="!client.enable">{{ t('disabled') }}</template>
|
||||||
|
<template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
|
||||||
|
<template v-else>{{ t('offline') }}</template>
|
||||||
|
</template>
|
||||||
|
<a-badge :color="statusBadgeColor(client)" />
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :title="client.email">
|
||||||
|
<span class="client-email">{{ client.email }}</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<div class="client-card-actions">
|
||||||
|
<a-switch :checked="client.enable" size="small"
|
||||||
|
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
||||||
|
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||||
|
<EllipsisOutlined class="row-icon" @click.prevent />
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item v-if="dbInbound.hasLink()" @click="emit('qrcode-client', { dbInbound, client })">
|
||||||
|
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="emit('edit-client', { dbInbound, client })">
|
||||||
|
<EditOutlined /> {{ t('edit') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="emit('info-client', { dbInbound, client })">
|
||||||
|
<InfoCircleOutlined /> {{ t('info') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item v-if="client.email" @click="confirmReset(client)">
|
||||||
|
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item v-if="isRemovable" @click="confirmDelete(client)">
|
||||||
|
<DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="client.comment && client.comment.trim()" class="client-comment-line">
|
||||||
|
{{ client.comment.length > 80 ? client.comment.substring(0, 77) + '…' : client.comment }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="client-card-foot">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
||||||
|
<a-tag :color="clientStatsColor(client.email)">
|
||||||
|
{{ SizeFormatter.sizeFormat(getSum(client.email)) }} /
|
||||||
|
<InfinityIcon v-if="isUnlimitedTotal(client)" />
|
||||||
|
<template v-else>{{ totalGbDisplay(client) }}</template>
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('remained') }}</span>
|
||||||
|
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
||||||
|
<InfinityIcon />
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
|
||||||
|
{{ SizeFormatter.sizeFormat(getRem(client.email)) }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
||||||
|
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('online') }}</span>
|
||||||
|
<a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
|
||||||
|
<a-tag v-else>{{ t('offline') }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
|
||||||
|
<a-tag v-if="client.expiryTime > 0" :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
|
||||||
|
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else-if="client.expiryTime < 0" color="green">
|
||||||
|
{{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else color="purple">
|
||||||
|
<InfinityIcon />
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"
|
||||||
|
:page-size="pageSize" :total="clients.length" :show-size-changer="false" size="small"
|
||||||
|
class="client-list-pagination" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.client-list {
|
||||||
|
margin: -8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: rgba(22, 119, 255, 0.08);
|
||||||
|
border-bottom: 1px solid rgba(22, 119, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-count {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-selected {
|
||||||
|
background: rgba(22, 119, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-row {
|
||||||
|
display: grid;
|
||||||
|
/* Default — no select column (single-client inbounds). The .has-select
|
||||||
|
* modifier below prepends the 40px checkbox column. */
|
||||||
|
grid-template-columns:
|
||||||
|
140px
|
||||||
|
/* actions */
|
||||||
|
60px
|
||||||
|
/* enable */
|
||||||
|
80px
|
||||||
|
/* online */
|
||||||
|
minmax(160px, 2fr)
|
||||||
|
/* client identity */
|
||||||
|
minmax(160px, 2fr)
|
||||||
|
/* traffic */
|
||||||
|
130px
|
||||||
|
/* all-time */
|
||||||
|
130px
|
||||||
|
/* remained */
|
||||||
|
140px;
|
||||||
|
/* expiry */
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-list.has-select .client-row {
|
||||||
|
grid-template-columns:
|
||||||
|
40px
|
||||||
|
/* select */
|
||||||
|
140px
|
||||||
|
/* actions */
|
||||||
|
60px
|
||||||
|
/* enable */
|
||||||
|
80px
|
||||||
|
/* online */
|
||||||
|
minmax(160px, 2fr)
|
||||||
|
/* client identity */
|
||||||
|
minmax(160px, 2fr)
|
||||||
|
/* traffic */
|
||||||
|
130px
|
||||||
|
/* all-time */
|
||||||
|
130px
|
||||||
|
/* remained */
|
||||||
|
140px;
|
||||||
|
/* expiry */
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-row:last-child {
|
||||||
|
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-list-header {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.65;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-top: none;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
min-width: 0;
|
||||||
|
/* allow grid children to shrink instead of overflowing */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-select,
|
||||||
|
.cell-actions,
|
||||||
|
.cell-enable,
|
||||||
|
.cell-online,
|
||||||
|
.cell-alltime,
|
||||||
|
.cell-remained {
|
||||||
|
text-align: center;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-client {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-traffic,
|
||||||
|
.cell-expiry {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-list-header .cell {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-list-header .cell-actions,
|
||||||
|
.client-list-header .cell-client {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action icons */
|
||||||
|
.row-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 2px;
|
||||||
|
color: inherit;
|
||||||
|
transition: color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-icon:hover {
|
||||||
|
color: var(--ant-color-primary, #1677ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-icon.danger {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Client identity stack (badge + email + comment) */
|
||||||
|
.client-id-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-email {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-comment {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Traffic / expiry inline bar: text | progress | text */
|
||||||
|
.usage-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(50px, auto) minmax(40px, 1fr) minmax(40px, auto);
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-text {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar :deep(.ant-progress) {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infinite-tag {
|
||||||
|
min-width: 50px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strip AD-Vue's default expanded-cell padding so the desktop grid
|
||||||
|
* sits flush against the inbound row's left/right edges. */
|
||||||
|
:deep(.ant-table-expanded-row > .ant-table-cell) {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-list-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 16px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Mobile card list =========================================== */
|
||||||
|
.client-list.is-mobile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-card {
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .client-card {
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-card-head .client-email {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-card-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-card-actions .row-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-comment-line {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-card-foot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-card-foot .stat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-card-foot .stat-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
opacity: 0.6;
|
||||||
|
min-width: 96px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-card-foot :deep(.ant-tag) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bigger status badge for thumb-readable state at a glance. */
|
||||||
|
.client-card-head :deep(.ant-badge-status-dot) {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,831 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
MenuOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
QrcodeOutlined,
|
||||||
|
UserAddOutlined,
|
||||||
|
UsergroupAddOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
FileDoneOutlined,
|
||||||
|
ExportOutlined,
|
||||||
|
ImportOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
RestOutlined,
|
||||||
|
RetweetOutlined,
|
||||||
|
BlockOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||||
|
import { DBInbound } from '@/models/dbinbound.js';
|
||||||
|
import { Inbound } from '@/models/inbound.js';
|
||||||
|
import InfinityIcon from '@/components/InfinityIcon.vue';
|
||||||
|
import ClientRowTable from './ClientRowTable.vue';
|
||||||
|
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||||
|
|
||||||
|
const { datepicker } = useDatepicker();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
dbInbounds: { type: Array, required: true },
|
||||||
|
clientCount: { type: Object, required: true },
|
||||||
|
onlineClients: { type: Array, required: true },
|
||||||
|
lastOnlineMap: { type: Object, default: () => ({}) },
|
||||||
|
expireDiff: { type: Number, default: 0 },
|
||||||
|
trafficDiff: { type: Number, default: 0 },
|
||||||
|
pageSize: { type: Number, default: 0 },
|
||||||
|
isMobile: { type: Boolean, default: false },
|
||||||
|
isDarkTheme: { type: Boolean, default: false },
|
||||||
|
subEnable: { type: Boolean, default: false },
|
||||||
|
// Map node id -> node row, supplied by the parent page so each
|
||||||
|
// inbound row can render its node name without an extra fetch.
|
||||||
|
nodesById: { type: Map, default: () => new Map() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'refresh',
|
||||||
|
'add-inbound',
|
||||||
|
'general-action',
|
||||||
|
'row-action',
|
||||||
|
// Per-client events surfaced from the expand-row table.
|
||||||
|
'edit-client',
|
||||||
|
'qrcode-client',
|
||||||
|
'info-client',
|
||||||
|
'reset-traffic-client',
|
||||||
|
'delete-client',
|
||||||
|
'delete-clients',
|
||||||
|
'toggle-enable-client',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ============ Toolbar / search & filter =============================
|
||||||
|
const enableFilter = ref(false);
|
||||||
|
const searchKey = ref('');
|
||||||
|
const filterBy = ref('');
|
||||||
|
|
||||||
|
// Toggle the filter mode — flip cleans the other input.
|
||||||
|
function onToggleFilter() {
|
||||||
|
if (enableFilter.value) searchKey.value = '';
|
||||||
|
else filterBy.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Search / filter projection =============================
|
||||||
|
// Mirrors the legacy logic: when searching, keep inbounds that match
|
||||||
|
// anywhere (deep search); when filtering, keep inbounds that have at
|
||||||
|
// least one client in the requested bucket and reduce their settings
|
||||||
|
// to that bucket.
|
||||||
|
function projectInbound(dbInbound, predicate) {
|
||||||
|
const next = new DBInbound(dbInbound);
|
||||||
|
let settings;
|
||||||
|
try {
|
||||||
|
settings = JSON.parse(dbInbound.settings || '{}');
|
||||||
|
} catch (_e) {
|
||||||
|
settings = {};
|
||||||
|
}
|
||||||
|
if (!Array.isArray(settings.clients)) return next;
|
||||||
|
const filtered = settings.clients.filter(predicate);
|
||||||
|
next.settings = Inbound.Settings.fromJson(dbInbound.protocol, { clients: filtered });
|
||||||
|
next.invalidateCache();
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleInbounds = computed(() => {
|
||||||
|
if (enableFilter.value) {
|
||||||
|
if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds];
|
||||||
|
const out = [];
|
||||||
|
for (const dbInbound of props.dbInbounds) {
|
||||||
|
const c = props.clientCount[dbInbound.id];
|
||||||
|
if (!c || !c[filterBy.value] || c[filterBy.value].length === 0) continue;
|
||||||
|
const list = c[filterBy.value];
|
||||||
|
out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (ObjectUtil.isEmpty(searchKey.value)) return [...props.dbInbounds];
|
||||||
|
const out = [];
|
||||||
|
for (const dbInbound of props.dbInbounds) {
|
||||||
|
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
|
||||||
|
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Columns =================================================
|
||||||
|
// `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
|
||||||
|
// `responsive` array still works on column defs. Computed so column
|
||||||
|
// labels react to live locale switches.
|
||||||
|
const hasAnyRemark = computed(() =>
|
||||||
|
props.dbInbounds.some((i) => typeof i?.remark === 'string' && i.remark.trim() !== ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
const desktopColumns = computed(() => {
|
||||||
|
const cols = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 },
|
||||||
|
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
|
||||||
|
{ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 },
|
||||||
|
];
|
||||||
|
if (hasAnyRemark.value) {
|
||||||
|
cols.push({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 });
|
||||||
|
}
|
||||||
|
if (props.nodesById.size > 0) {
|
||||||
|
cols.push({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 });
|
||||||
|
}
|
||||||
|
cols.push(
|
||||||
|
{ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 },
|
||||||
|
{ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 },
|
||||||
|
{ title: t('clients'), key: 'clients', align: 'left', width: 50 },
|
||||||
|
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 },
|
||||||
|
{ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 },
|
||||||
|
{ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 },
|
||||||
|
);
|
||||||
|
return cols;
|
||||||
|
});
|
||||||
|
const columns = computed(() => desktopColumns.value);
|
||||||
|
|
||||||
|
// Mobile expansion state — replaces a-table's expandable() since the
|
||||||
|
// mobile branch renders a hand-rolled card list rather than a table.
|
||||||
|
const expandedIds = ref(new Set());
|
||||||
|
function toggleExpanded(id) {
|
||||||
|
const next = new Set(expandedIds.value);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
expandedIds.value = next;
|
||||||
|
}
|
||||||
|
function isExpanded(id) {
|
||||||
|
return expandedIds.value.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Pagination ============================================
|
||||||
|
function paginationFor(rows) {
|
||||||
|
const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
|
||||||
|
return {
|
||||||
|
pageSize: size,
|
||||||
|
showSizeChanger: false,
|
||||||
|
hideOnSinglePage: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Per-row enable switch =================================
|
||||||
|
async function onSwitchEnable(dbInbound, next) {
|
||||||
|
const previous = dbInbound.enable;
|
||||||
|
dbInbound.enable = next; // optimistic
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('enable', String(next));
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
|
||||||
|
if (!msg?.success) dbInbound.enable = previous;
|
||||||
|
} catch (_e) {
|
||||||
|
dbInbound.enable = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Helpers shared with the templates =====================
|
||||||
|
// Whether to show the "Switch xray" / qrcode menu entry — same predicate
|
||||||
|
// as legacy: SS single-user inbounds and WireGuard inbounds expose
|
||||||
|
// inbound-wide QR codes.
|
||||||
|
function showQrCodeMenu(dbInbound) {
|
||||||
|
if (dbInbound.isWireguard) return true;
|
||||||
|
if (dbInbound.isSS) {
|
||||||
|
try {
|
||||||
|
return !dbInbound.toInbound().isSSMultiUser;
|
||||||
|
} catch (_e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-card hoverable>
|
||||||
|
<template #title>
|
||||||
|
<a-space direction="horizontal">
|
||||||
|
<a-button type="primary" @click="emit('add-inbound')">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
<template v-if="!isMobile">{{ t('pages.inbounds.addInbound') }}</template>
|
||||||
|
</a-button>
|
||||||
|
<a-dropdown :trigger="['click']">
|
||||||
|
<a-button type="primary">
|
||||||
|
<template #icon>
|
||||||
|
<MenuOutlined />
|
||||||
|
</template>
|
||||||
|
<template v-if="!isMobile">{{ t('pages.inbounds.generalActions') }}</template>
|
||||||
|
</a-button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu @click="(a) => emit('general-action', a.key)">
|
||||||
|
<a-menu-item key="import">
|
||||||
|
<ImportOutlined /> {{ t('pages.inbounds.importInbound') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="export">
|
||||||
|
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item v-if="subEnable" key="subs">
|
||||||
|
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="resetInbounds">
|
||||||
|
<ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="resetClients">
|
||||||
|
<FileDoneOutlined /> {{ t('pages.inbounds.resetAllClientTraffics') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||||
|
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-space direction="vertical" :style="{ width: '100%' }">
|
||||||
|
<!-- Search / filter toolbar -->
|
||||||
|
<div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
|
||||||
|
<a-switch v-model:checked="enableFilter" @change="onToggleFilter">
|
||||||
|
<template #checkedChildren>
|
||||||
|
<SearchOutlined />
|
||||||
|
</template>
|
||||||
|
<template #unCheckedChildren>
|
||||||
|
<FilterOutlined />
|
||||||
|
</template>
|
||||||
|
</a-switch>
|
||||||
|
<a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
|
||||||
|
:size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
|
||||||
|
<a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
|
||||||
|
:size="isMobile ? 'small' : 'middle'">
|
||||||
|
<a-radio-button value="">{{ t('none') }}</a-radio-button>
|
||||||
|
<a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
|
||||||
|
<a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
|
||||||
|
<a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
|
||||||
|
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
|
||||||
|
<a-radio-button value="online">{{ t('online') }}</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================== Mobile: card list ======================= -->
|
||||||
|
<div v-if="isMobile" class="inbound-cards">
|
||||||
|
<div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
|
||||||
|
|
||||||
|
<div v-for="record in visibleInbounds" :key="record.id" class="inbound-card">
|
||||||
|
<!-- Header: chevron (multi-user only) + remark + enable + actions -->
|
||||||
|
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
|
||||||
|
<RightOutlined v-if="record.isMultiUser()" class="card-expand"
|
||||||
|
:class="{ 'is-expanded': isExpanded(record.id) }" />
|
||||||
|
<span class="card-id">#{{ record.id }}</span>
|
||||||
|
<span class="tag-name">{{ record.remark }}</span>
|
||||||
|
<div class="card-actions" @click.stop>
|
||||||
|
<a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
|
||||||
|
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||||
|
<MoreOutlined class="row-action-trigger" @click.prevent />
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
|
||||||
|
<a-menu-item key="edit">
|
||||||
|
<EditOutlined /> {{ t('edit') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
|
||||||
|
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<template v-if="record.isMultiUser()">
|
||||||
|
<a-menu-item key="addClient">
|
||||||
|
<UserAddOutlined /> {{ t('pages.client.add') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="addBulkClient">
|
||||||
|
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="copyClients">
|
||||||
|
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="resetClients">
|
||||||
|
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="export">
|
||||||
|
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item v-if="subEnable" key="subs">
|
||||||
|
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||||
|
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a-menu-item key="showInfo">
|
||||||
|
<InfoCircleOutlined /> {{ t('info') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</template>
|
||||||
|
<a-menu-item key="clipboard">
|
||||||
|
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="resetTraffic">
|
||||||
|
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="clone">
|
||||||
|
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="delete" class="danger-item">
|
||||||
|
<DeleteOutlined /> {{ t('delete') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2-column labelled stat grid: protocol/port/node + traffic/clients/expiry -->
|
||||||
|
<div class="card-stats">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
|
||||||
|
<a-tag color="purple">{{ record.protocol }}</a-tag>
|
||||||
|
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
|
||||||
|
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
|
||||||
|
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
|
||||||
|
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.port') }}</span>
|
||||||
|
<a-tag>{{ record.port }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="nodesById.size > 0" class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.node') }}</span>
|
||||||
|
<a-tag v-if="record.nodeId == null" color="default">
|
||||||
|
{{ t('pages.inbounds.localPanel') }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else-if="nodesById.get(record.nodeId)"
|
||||||
|
:color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
|
||||||
|
{{ nodesById.get(record.nodeId).name }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else color="orange">#{{ record.nodeId }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
||||||
|
<a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
|
||||||
|
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
|
||||||
|
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
|
||||||
|
<InfinityIcon v-else />
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
||||||
|
<a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="clientCount[record.id]" class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('clients') }}</span>
|
||||||
|
<a-tag color="green">{{ clientCount[record.id].clients }}</a-tag>
|
||||||
|
<a-tag v-if="clientCount[record.id].online.length" color="blue">
|
||||||
|
{{ clientCount[record.id].online.length }} {{ t('online') }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="clientCount[record.id].depleted.length" color="red">
|
||||||
|
{{ clientCount[record.id].depleted.length }} {{ t('depleted') }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="clientCount[record.id].expiring.length" color="orange">
|
||||||
|
{{ clientCount[record.id].expiring.length }} {{ t('depletingSoon') }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
|
||||||
|
<a-tag v-if="record.expiryTime > 0"
|
||||||
|
:color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)">
|
||||||
|
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else color="purple">
|
||||||
|
<InfinityIcon />
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded client list (multi-user only) -->
|
||||||
|
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
|
||||||
|
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
|
||||||
|
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
|
||||||
|
:page-size="pageSize"
|
||||||
|
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
|
||||||
|
@info-client="(p) => emit('info-client', p)"
|
||||||
|
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||||
|
@delete-client="(p) => emit('delete-client', p)"
|
||||||
|
@delete-clients="(p) => emit('delete-clients', p)"
|
||||||
|
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================== Desktop: a-table ======================== -->
|
||||||
|
<a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
|
||||||
|
:pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
|
||||||
|
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
|
||||||
|
<!-- Per-inbound client list, expanded by clicking the row's
|
||||||
|
default expand chevron. Hidden via row-class-name for
|
||||||
|
non-multi-user inbounds (matches legacy behavior). -->
|
||||||
|
<template #expandedRowRender="{ record }">
|
||||||
|
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
|
||||||
|
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
|
||||||
|
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
|
||||||
|
@edit-client="(p) => emit('edit-client', p)"
|
||||||
|
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
|
||||||
|
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||||
|
@delete-client="(p) => emit('delete-client', p)"
|
||||||
|
@delete-clients="(p) => emit('delete-clients', p)"
|
||||||
|
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<!-- ============== Action dropdown ============== -->
|
||||||
|
<template v-if="column.key === 'action'">
|
||||||
|
<a-dropdown :trigger="['click']">
|
||||||
|
<MoreOutlined class="row-action-trigger" @click.prevent />
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
|
||||||
|
<a-menu-item key="edit">
|
||||||
|
<EditOutlined /> {{ t('edit') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
|
||||||
|
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<template v-if="record.isMultiUser()">
|
||||||
|
<a-menu-item key="addClient">
|
||||||
|
<UserAddOutlined /> {{ t('pages.client.add') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="addBulkClient">
|
||||||
|
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="copyClients">
|
||||||
|
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="resetClients">
|
||||||
|
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="export">
|
||||||
|
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item v-if="subEnable" key="subs">
|
||||||
|
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||||
|
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a-menu-item key="showInfo">
|
||||||
|
<InfoCircleOutlined /> {{ t('info') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</template>
|
||||||
|
<a-menu-item key="clipboard">
|
||||||
|
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="resetTraffic">
|
||||||
|
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="clone">
|
||||||
|
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="delete" class="danger-item">
|
||||||
|
<DeleteOutlined /> {{ t('delete') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============== Enable switch (desktop) ============== -->
|
||||||
|
<template v-else-if="column.key === 'enable'">
|
||||||
|
<a-switch :checked="record.enable" @change="(next) => onSwitchEnable(record, next)" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============== Node deployment tag ============== -->
|
||||||
|
<template v-else-if="column.key === 'node'">
|
||||||
|
<template v-if="record.nodeId == null">
|
||||||
|
<a-tag color="default">{{ t('pages.inbounds.localPanel') }}</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="nodesById.get(record.nodeId)">
|
||||||
|
<a-tag :color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
|
||||||
|
{{ nodesById.get(record.nodeId).name }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<!-- Node row was deleted but inbound still references it. -->
|
||||||
|
<a-tag color="orange">node #{{ record.nodeId }}</a-tag>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============== Protocol tags ============== -->
|
||||||
|
<template v-else-if="column.key === 'protocol'">
|
||||||
|
<div class="protocol-tags">
|
||||||
|
<a-tag color="purple">{{ record.protocol }}</a-tag>
|
||||||
|
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
|
||||||
|
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
|
||||||
|
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
|
||||||
|
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============== Clients tag + popovers ============== -->
|
||||||
|
<template v-else-if="column.key === 'clients'">
|
||||||
|
<template v-if="clientCount[record.id]">
|
||||||
|
<a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag>
|
||||||
|
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
|
||||||
|
<template #content>
|
||||||
|
<div class="client-email-list">
|
||||||
|
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
|
||||||
|
</a-popover>
|
||||||
|
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
|
||||||
|
<template #content>
|
||||||
|
<div class="client-email-list">
|
||||||
|
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
|
||||||
|
}}</a-tag>
|
||||||
|
</a-popover>
|
||||||
|
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
|
||||||
|
<template #content>
|
||||||
|
<div class="client-email-list">
|
||||||
|
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
|
||||||
|
}}</a-tag>
|
||||||
|
</a-popover>
|
||||||
|
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
|
||||||
|
<template #content>
|
||||||
|
<div class="client-email-list">
|
||||||
|
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
|
||||||
|
</a-popover>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============== Traffic ============== -->
|
||||||
|
<template v-else-if="column.key === 'traffic'">
|
||||||
|
<a-popover>
|
||||||
|
<template #content>
|
||||||
|
<table cellpadding="2">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>↑ {{ SizeFormatter.sizeFormat(record.up) }}</td>
|
||||||
|
<td>↓ {{ SizeFormatter.sizeFormat(record.down) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="record.total > 0 && record.up + record.down < record.total">
|
||||||
|
<td>{{ t('remained') }}</td>
|
||||||
|
<td>{{ SizeFormatter.sizeFormat(record.total - record.up - record.down) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
<a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
|
||||||
|
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
|
||||||
|
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
|
||||||
|
<InfinityIcon v-else />
|
||||||
|
</a-tag>
|
||||||
|
</a-popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============== All-time inbound traffic ============== -->
|
||||||
|
<template v-else-if="column.key === 'allTimeInbound'">
|
||||||
|
<a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============== Expiry ============== -->
|
||||||
|
<template v-else-if="column.key === 'expiryTime'">
|
||||||
|
<a-popover v-if="record.expiryTime > 0">
|
||||||
|
<template #content>{{ IntlUtil.formatDate(record.expiryTime, datepicker) }}</template>
|
||||||
|
<a-tag :color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)" style="min-width: 50px">
|
||||||
|
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
|
||||||
|
</a-tag>
|
||||||
|
</a-popover>
|
||||||
|
<a-tag v-else color="purple">
|
||||||
|
<InfinityIcon />
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-space>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar.mobile {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar.mobile>* {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-tags {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-action-trigger {
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-item {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the expand chevron on rows whose inbound has no client list
|
||||||
|
* (HTTP/Mixed/Tunnel/WireGuard single-config). */
|
||||||
|
:deep(.hide-expand-icon .ant-table-row-expand-icon) {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Push the expand chevron away from the table's left edge so it has
|
||||||
|
* a little breathing room instead of being flush against the corner. */
|
||||||
|
:deep(.ant-table-tbody .ant-table-cell-with-append) {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-table-row-expand-icon) {
|
||||||
|
margin-inline-end: 10px;
|
||||||
|
margin-inline-start: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Round the table's outer corners — AD-Vue gives .ant-table the radius
|
||||||
|
* token, but the inner header strip and footer touch the edges, so clip
|
||||||
|
* them here. */
|
||||||
|
:deep(.ant-table) {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-table-container) {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-table-thead > tr:first-child > *:first-child) {
|
||||||
|
border-start-start-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-table-thead > tr:first-child > *:last-child) {
|
||||||
|
border-start-end-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-table-tbody > tr:last-child > *:first-child) {
|
||||||
|
border-end-start-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-table-tbody > tr:last-child > *:last-child) {
|
||||||
|
border-end-end-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Mobile card list ===========================================
|
||||||
|
* <768px renders inbounds as a vertical stack of cards via the
|
||||||
|
* v-if="isMobile" branch above; the desktop <a-table> isn't mounted
|
||||||
|
* so the legacy table-cell tightening rules went away. */
|
||||||
|
.inbound-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbound-card {
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .inbound-card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-id {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-name {
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-expand {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-expand.is-expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
opacity: 0.6;
|
||||||
|
min-width: 96px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-stats :deep(.ant-tag) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-clients {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-empty {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.4;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:deep(.ant-card-head) {
|
||||||
|
padding: 0 12px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-card-head-title),
|
||||||
|
:deep(.ant-card-extra) {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-card-body) {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar.mobile {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar.mobile>* {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-action-trigger {
|
||||||
|
font-size: 22px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,748 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Modal, message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
SwapOutlined,
|
||||||
|
PieChartOutlined,
|
||||||
|
HistoryOutlined,
|
||||||
|
BarsOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||||
|
import { Inbound } from '@/models/inbound.js';
|
||||||
|
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||||
|
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||||
|
import AppSidebar from '@/components/AppSidebar.vue';
|
||||||
|
import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||||
|
import { useNodeList } from '@/composables/useNodeList.js';
|
||||||
|
import InboundList from './InboundList.vue';
|
||||||
|
import InboundFormModal from './InboundFormModal.vue';
|
||||||
|
import ClientFormModal from './ClientFormModal.vue';
|
||||||
|
import ClientBulkModal from './ClientBulkModal.vue';
|
||||||
|
import InboundInfoModal from './InboundInfoModal.vue';
|
||||||
|
import QrCodeModal from './QrCodeModal.vue';
|
||||||
|
import TextModal from '@/components/TextModal.vue';
|
||||||
|
import PromptModal from '@/components/PromptModal.vue';
|
||||||
|
import { useInbounds } from './useInbounds.js';
|
||||||
|
import { useWebSocket } from '@/composables/useWebSocket.js';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const {
|
||||||
|
fetched,
|
||||||
|
dbInbounds,
|
||||||
|
clientCount,
|
||||||
|
onlineClients,
|
||||||
|
totals,
|
||||||
|
expireDiff,
|
||||||
|
trafficDiff,
|
||||||
|
pageSize,
|
||||||
|
subSettings,
|
||||||
|
tgBotEnable,
|
||||||
|
ipLimitEnable,
|
||||||
|
remarkModel,
|
||||||
|
lastOnlineMap,
|
||||||
|
refresh,
|
||||||
|
fetchDefaultSettings,
|
||||||
|
applyTrafficEvent,
|
||||||
|
applyClientStatsEvent,
|
||||||
|
applyInvalidate,
|
||||||
|
applyInboundsEvent,
|
||||||
|
} = useInbounds();
|
||||||
|
|
||||||
|
// Live updates over WebSocket — replaces the old 5s polling loop.
|
||||||
|
// The backend pushes traffic + per-client deltas every ~10s; we merge
|
||||||
|
// them into the local refs in-place so counters and online badges
|
||||||
|
// update without re-fetching the whole list.
|
||||||
|
useWebSocket({
|
||||||
|
traffic: applyTrafficEvent,
|
||||||
|
client_stats: applyClientStatsEvent,
|
||||||
|
invalidate: applyInvalidate,
|
||||||
|
inbounds: applyInboundsEvent,
|
||||||
|
});
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
// Node list lives on the central panel; the Inbounds page consumes
|
||||||
|
// the id→node map for the new "Node" column. Fetched once on mount.
|
||||||
|
const { byId: nodesById } = useNodeList();
|
||||||
|
|
||||||
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
|
const requestUri = window.location.pathname;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchDefaultSettings();
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Add/Edit modal ===================================================
|
||||||
|
const formOpen = ref(false);
|
||||||
|
const formMode = ref('add');
|
||||||
|
const formDbInbound = ref(null);
|
||||||
|
|
||||||
|
// === Client modal (single + bulk) =====================================
|
||||||
|
const clientOpen = ref(false);
|
||||||
|
const clientMode = ref('add');
|
||||||
|
const clientDbInbound = ref(null);
|
||||||
|
const clientIndex = ref(null);
|
||||||
|
|
||||||
|
const bulkOpen = ref(false);
|
||||||
|
const bulkDbInbound = ref(null);
|
||||||
|
|
||||||
|
// === Info / QR-code modals ===========================================
|
||||||
|
const infoOpen = ref(false);
|
||||||
|
const infoDbInbound = ref(null);
|
||||||
|
const infoClientIndex = ref(0);
|
||||||
|
|
||||||
|
const qrOpen = ref(false);
|
||||||
|
const qrDbInbound = ref(null);
|
||||||
|
const qrClient = ref(null);
|
||||||
|
|
||||||
|
// hostOverrideFor returns the node's address for a node-managed inbound,
|
||||||
|
// or '' when the inbound runs locally. Wired into the QR / Info modals
|
||||||
|
// and into export-all-links functions so generated share links point at
|
||||||
|
// the node, not the central panel.
|
||||||
|
function hostOverrideFor(dbInbound) {
|
||||||
|
if (!dbInbound || dbInbound.nodeId == null) return '';
|
||||||
|
return nodesById.value.get(dbInbound.nodeId)?.address || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoNodeAddress = computed(() => hostOverrideFor(infoDbInbound.value));
|
||||||
|
const qrNodeAddress = computed(() => hostOverrideFor(qrDbInbound.value));
|
||||||
|
|
||||||
|
// === Shared text + prompt modal state =================================
|
||||||
|
const textOpen = ref(false);
|
||||||
|
const textTitle = ref('');
|
||||||
|
const textContent = ref('');
|
||||||
|
const textFileName = ref('');
|
||||||
|
|
||||||
|
const promptOpen = ref(false);
|
||||||
|
const promptTitle = ref('');
|
||||||
|
const promptOkText = ref('OK');
|
||||||
|
const promptType = ref('textarea');
|
||||||
|
const promptInitial = ref('');
|
||||||
|
const promptLoading = ref(false);
|
||||||
|
let promptHandler = null;
|
||||||
|
|
||||||
|
function openText({ title, content, fileName = '' }) {
|
||||||
|
textTitle.value = title;
|
||||||
|
textContent.value = content;
|
||||||
|
textFileName.value = fileName;
|
||||||
|
textOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPrompt({ title, okText, type = 'textarea', value = '', confirm }) {
|
||||||
|
promptTitle.value = title;
|
||||||
|
promptOkText.value = okText || 'OK';
|
||||||
|
promptType.value = type;
|
||||||
|
promptInitial.value = value;
|
||||||
|
promptHandler = confirm;
|
||||||
|
promptOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPromptConfirm(value) {
|
||||||
|
if (!promptHandler) { promptOpen.value = false; return; }
|
||||||
|
promptLoading.value = true;
|
||||||
|
try {
|
||||||
|
const ok = await promptHandler(value);
|
||||||
|
if (ok !== false) promptOpen.value = false;
|
||||||
|
} finally {
|
||||||
|
promptLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Export helpers — mirror legacy txtModal call sites ==============
|
||||||
|
function exportInboundLinks(dbInbound) {
|
||||||
|
const projected = checkFallback(dbInbound);
|
||||||
|
openText({
|
||||||
|
title: 'Export inbound links',
|
||||||
|
content: projected.genInboundLinks(remarkModel.value, hostOverrideFor(dbInbound)),
|
||||||
|
fileName: projected.remark || 'inbound',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportInboundClipboard(dbInbound) {
|
||||||
|
openText({
|
||||||
|
title: 'Inbound JSON',
|
||||||
|
content: JSON.stringify(dbInbound, null, 2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportInboundSubs(dbInbound) {
|
||||||
|
const inbound = dbInbound.toInbound();
|
||||||
|
const clients = inbound?.clients || [];
|
||||||
|
const subLinks = [];
|
||||||
|
for (const c of clients) {
|
||||||
|
if (c.subId && subSettings.value.subURI) {
|
||||||
|
subLinks.push(subSettings.value.subURI + c.subId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openText({
|
||||||
|
title: 'Export subscription links',
|
||||||
|
content: [...new Set(subLinks)].join('\n'),
|
||||||
|
fileName: `${dbInbound.remark || 'inbound'}-Subs`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportAllLinks() {
|
||||||
|
const out = [];
|
||||||
|
for (const ib of dbInbounds.value) {
|
||||||
|
out.push(ib.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
|
||||||
|
}
|
||||||
|
openText({
|
||||||
|
title: 'Export all inbound links',
|
||||||
|
content: out.join('\r\n'),
|
||||||
|
fileName: 'All-Inbounds',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportAllSubs() {
|
||||||
|
const out = [];
|
||||||
|
for (const ib of dbInbounds.value) {
|
||||||
|
const inbound = ib.toInbound();
|
||||||
|
const clients = inbound?.clients || [];
|
||||||
|
for (const c of clients) {
|
||||||
|
if (c.subId && subSettings.value.subURI) {
|
||||||
|
out.push(subSettings.value.subURI + c.subId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openText({
|
||||||
|
title: 'Export all subscription links',
|
||||||
|
content: [...new Set(out)].join('\r\n'),
|
||||||
|
fileName: 'All-Inbounds-Subs',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function importInbound() {
|
||||||
|
openPrompt({
|
||||||
|
title: 'Import inbound',
|
||||||
|
okText: 'Import',
|
||||||
|
type: 'textarea',
|
||||||
|
value: '',
|
||||||
|
confirm: async (value) => {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
|
||||||
|
if (msg?.success) {
|
||||||
|
await refresh();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// `checkFallback` mirrors the legacy helper: when an inbound listens
|
||||||
|
// on a unix-socket fallback (`@<name>`), point the link generator at
|
||||||
|
// the root inbound that owns the listen address so QRs/links carry
|
||||||
|
// the externally-reachable host:port and the right TLS state.
|
||||||
|
function checkFallback(dbInbound) {
|
||||||
|
// We don't keep parsed Inbounds in state right now (the page works
|
||||||
|
// off DBInbounds); compute on the fly.
|
||||||
|
if (!dbInbound.listen?.startsWith?.('@')) return dbInbound;
|
||||||
|
for (const candidate of dbInbounds.value) {
|
||||||
|
if (candidate.id === dbInbound.id) continue;
|
||||||
|
const parsed = candidate.toInbound();
|
||||||
|
if (!parsed.isTcp) continue;
|
||||||
|
if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
|
||||||
|
const fallbacks = parsed.settings.fallbacks || [];
|
||||||
|
if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
|
||||||
|
// Build a one-off DBInbound copy with the parent's listen/port +
|
||||||
|
// copied stream so the link gen sees the public endpoint.
|
||||||
|
const projected = JSON.parse(JSON.stringify(dbInbound));
|
||||||
|
projected.listen = candidate.listen;
|
||||||
|
projected.port = candidate.port;
|
||||||
|
const inheritedStream = parsed.stream;
|
||||||
|
const ownInbound = dbInbound.toInbound();
|
||||||
|
ownInbound.stream.security = inheritedStream.security;
|
||||||
|
ownInbound.stream.tls = inheritedStream.tls;
|
||||||
|
ownInbound.stream.externalProxy = inheritedStream.externalProxy;
|
||||||
|
projected.streamSettings = ownInbound.stream.toString();
|
||||||
|
// Re-wrap so callers get the same DBInbound shape they had.
|
||||||
|
return new dbInbound.constructor(projected);
|
||||||
|
}
|
||||||
|
return dbInbound;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findClientIndex(dbInbound, client) {
|
||||||
|
if (!client) return 0;
|
||||||
|
const inbound = dbInbound.toInbound();
|
||||||
|
const clients = inbound?.clients || [];
|
||||||
|
const idx = clients.findIndex((c) => {
|
||||||
|
if (!c) return false;
|
||||||
|
switch (dbInbound.protocol) {
|
||||||
|
case 'trojan':
|
||||||
|
case 'shadowsocks':
|
||||||
|
return c.password === client.password && c.email === client.email;
|
||||||
|
default:
|
||||||
|
return c.id === client.id && c.email === client.email;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientId(protocol, client) {
|
||||||
|
switch (protocol) {
|
||||||
|
case 'trojan': return client.password;
|
||||||
|
case 'shadowsocks': return client.email;
|
||||||
|
case 'hysteria': return client.auth;
|
||||||
|
default: return client.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Per-client handlers (called from the expand-row table) =========
|
||||||
|
function onEditClient({ dbInbound, client }) {
|
||||||
|
clientMode.value = 'edit';
|
||||||
|
clientDbInbound.value = dbInbound;
|
||||||
|
clientIndex.value = findClientIndex(dbInbound, client);
|
||||||
|
clientOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQrcodeClient({ dbInbound, client }) {
|
||||||
|
qrDbInbound.value = checkFallback(dbInbound);
|
||||||
|
qrClient.value = client || null;
|
||||||
|
qrOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInfoClient({ dbInbound, client }) {
|
||||||
|
infoDbInbound.value = checkFallback(dbInbound);
|
||||||
|
infoClientIndex.value = findClientIndex(dbInbound, client);
|
||||||
|
infoOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onResetTrafficClient({ dbInbound, client }) {
|
||||||
|
const msg = await HttpUtil.post(
|
||||||
|
`/panel/api/inbounds/${dbInbound.id}/resetClientTraffic/${client.email}`,
|
||||||
|
);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeleteClient({ dbInbound, client }) {
|
||||||
|
const clientId = getClientId(dbInbound.protocol, client);
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeleteClients({ dbInbound, clients }) {
|
||||||
|
for (const client of clients) {
|
||||||
|
const clientId = getClientId(dbInbound.protocol, client);
|
||||||
|
await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleEnableClient({ dbInbound, client, next }) {
|
||||||
|
// Mirror legacy: clone the parsed inbound, flip enable on the matching
|
||||||
|
// client, and post the whole client back through updateClient. This
|
||||||
|
// keeps the wire shape identical to the modal save path.
|
||||||
|
const inbound = dbInbound.toInbound();
|
||||||
|
const clients = inbound?.clients || [];
|
||||||
|
const idx = findClientIndex(dbInbound, client);
|
||||||
|
if (idx < 0 || !clients[idx]) return;
|
||||||
|
clients[idx].enable = next;
|
||||||
|
const clientId = getClientId(dbInbound.protocol, clients[idx]);
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/updateClient/${clientId}`, {
|
||||||
|
id: dbInbound.id,
|
||||||
|
settings: `{"clients": [${clients[idx].toString()}]}`,
|
||||||
|
});
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddInbound() {
|
||||||
|
formMode.value = 'add';
|
||||||
|
formDbInbound.value = null;
|
||||||
|
formOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(dbInbound) {
|
||||||
|
formMode.value = 'edit';
|
||||||
|
formDbInbound.value = dbInbound;
|
||||||
|
formOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddClient(dbInbound) {
|
||||||
|
clientMode.value = 'add';
|
||||||
|
clientDbInbound.value = dbInbound;
|
||||||
|
clientIndex.value = null;
|
||||||
|
clientOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddBulkClient(dbInbound) {
|
||||||
|
bulkDbInbound.value = dbInbound;
|
||||||
|
bulkOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-row destructive actions go through Modal.confirm (matches legacy).
|
||||||
|
function confirmDelete(dbInbound) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Delete inbound "${dbInbound.remark}"?`,
|
||||||
|
content: 'This removes the inbound and all its clients. This cannot be undone.',
|
||||||
|
okText: 'Delete',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmResetTraffic(dbInbound) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Reset traffic for "${dbInbound.remark}"?`,
|
||||||
|
content: 'Resets up/down counters to 0 for this inbound.',
|
||||||
|
okText: 'Reset',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllTraffics`);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelDepleted(dbInboundId) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Delete depleted clients?',
|
||||||
|
content: 'Removes every client whose traffic is exhausted or whose expiry has passed.',
|
||||||
|
okText: 'Delete',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/delDepletedClients/${dbInboundId}`);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone — adds a new inbound with the same protocol+stream+sniffing
|
||||||
|
// but a fresh remark/port and an empty client list.
|
||||||
|
function confirmClone(dbInbound) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Clone inbound "${dbInbound.remark}"?`,
|
||||||
|
content: 'Creates a copy with a new port and an empty client list.',
|
||||||
|
okText: 'Clone',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
const baseInbound = dbInbound.toInbound();
|
||||||
|
const data = {
|
||||||
|
up: 0,
|
||||||
|
down: 0,
|
||||||
|
total: 0,
|
||||||
|
remark: `${dbInbound.remark} (clone)`,
|
||||||
|
enable: false,
|
||||||
|
expiryTime: 0,
|
||||||
|
listen: '',
|
||||||
|
port: RandomUtil.randomInteger(10000, 60000),
|
||||||
|
protocol: baseInbound.protocol,
|
||||||
|
settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
|
||||||
|
streamSettings: baseInbound.stream.toString(),
|
||||||
|
sniffing: baseInbound.sniffing.toString(),
|
||||||
|
};
|
||||||
|
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGeneralAction(key) {
|
||||||
|
switch (key) {
|
||||||
|
case 'import':
|
||||||
|
importInbound();
|
||||||
|
break;
|
||||||
|
case 'export':
|
||||||
|
exportAllLinks();
|
||||||
|
break;
|
||||||
|
case 'subs':
|
||||||
|
exportAllSubs();
|
||||||
|
break;
|
||||||
|
case 'resetInbounds':
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Reset all inbound traffic?',
|
||||||
|
okText: 'Reset',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'resetClients':
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Reset all client traffic across all inbounds?',
|
||||||
|
okText: 'Reset',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllClientTraffics/-1');
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'delDepletedClients':
|
||||||
|
confirmDelDepleted(-1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message.info(`General action "${key}" — coming in a later 5f subphase`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowAction({ key, dbInbound }) {
|
||||||
|
switch (key) {
|
||||||
|
case 'edit':
|
||||||
|
openEdit(dbInbound);
|
||||||
|
break;
|
||||||
|
case 'addClient':
|
||||||
|
openAddClient(dbInbound);
|
||||||
|
break;
|
||||||
|
case 'addBulkClient':
|
||||||
|
openAddBulkClient(dbInbound);
|
||||||
|
break;
|
||||||
|
case 'showInfo':
|
||||||
|
infoDbInbound.value = checkFallback(dbInbound);
|
||||||
|
infoClientIndex.value = findClientIndex(dbInbound, null);
|
||||||
|
infoOpen.value = true;
|
||||||
|
break;
|
||||||
|
case 'qrcode':
|
||||||
|
qrDbInbound.value = checkFallback(dbInbound);
|
||||||
|
qrClient.value = null;
|
||||||
|
qrOpen.value = true;
|
||||||
|
break;
|
||||||
|
case 'export':
|
||||||
|
exportInboundLinks(dbInbound);
|
||||||
|
break;
|
||||||
|
case 'subs':
|
||||||
|
exportInboundSubs(dbInbound);
|
||||||
|
break;
|
||||||
|
case 'clipboard':
|
||||||
|
exportInboundClipboard(dbInbound);
|
||||||
|
break;
|
||||||
|
case 'copyClients':
|
||||||
|
// Copy-clients-from-inbound is a tiny dedicated modal in legacy
|
||||||
|
// (lets you tick clients to copy across inbounds). Defer to a
|
||||||
|
// future commit — surface a friendly message for now.
|
||||||
|
message.info('Copy clients across inbounds — coming soon');
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
confirmDelete(dbInbound);
|
||||||
|
break;
|
||||||
|
case 'resetTraffic':
|
||||||
|
confirmResetTraffic(dbInbound);
|
||||||
|
break;
|
||||||
|
case 'clone':
|
||||||
|
confirmClone(dbInbound);
|
||||||
|
break;
|
||||||
|
case 'resetClients':
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Reset client traffic on "${dbInbound.remark}"?`,
|
||||||
|
okText: 'Reset',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllClientTraffics/${dbInbound.id}`);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'delDepletedClients':
|
||||||
|
confirmDelDepleted(dbInbound.id);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message.info(`Action "${key}" — coming in a later 5f subphase`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-config-provider :theme="antdThemeConfig">
|
||||||
|
<a-layout class="inbounds-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||||
|
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||||
|
|
||||||
|
<a-layout class="content-shell">
|
||||||
|
<a-layout-content id="content-layout" class="content-area">
|
||||||
|
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
||||||
|
<div v-if="!fetched" class="loading-spacer" />
|
||||||
|
|
||||||
|
<a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
|
||||||
|
<!-- Summary statistics card -->
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-card size="small" hoverable class="summary-card">
|
||||||
|
<a-row :gutter="[16, 12]">
|
||||||
|
<a-col :xs="12" :sm="12" :md="5">
|
||||||
|
<CustomStatistic :title="t('pages.inbounds.totalDownUp')"
|
||||||
|
:value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
|
||||||
|
<template #prefix>
|
||||||
|
<SwapOutlined />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="12" :sm="12" :md="5">
|
||||||
|
<CustomStatistic :title="t('pages.inbounds.totalUsage')"
|
||||||
|
:value="SizeFormatter.sizeFormat(totals.up + totals.down)">
|
||||||
|
<template #prefix>
|
||||||
|
<PieChartOutlined />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="12" :sm="12" :md="5">
|
||||||
|
<CustomStatistic :title="t('pages.inbounds.allTimeTrafficUsage')"
|
||||||
|
:value="SizeFormatter.sizeFormat(totals.allTime)">
|
||||||
|
<template #prefix>
|
||||||
|
<HistoryOutlined />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="12" :sm="12" :md="5">
|
||||||
|
<CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
|
||||||
|
<template #prefix>
|
||||||
|
<BarsOutlined />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="24" :md="4">
|
||||||
|
<CustomStatistic :title="t('clients')" value=" ">
|
||||||
|
<template #prefix>
|
||||||
|
<a-space direction="horizontal">
|
||||||
|
<TeamOutlined />
|
||||||
|
<a-tag color="green">{{ totals.clients }}</a-tag>
|
||||||
|
<a-popover v-if="totals.deactive.length" :title="t('disabled')">
|
||||||
|
<template #content>
|
||||||
|
<div class="client-email-list">
|
||||||
|
<div v-for="email in totals.deactive" :key="email">{{ email }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<a-tag>{{ totals.deactive.length }}</a-tag>
|
||||||
|
</a-popover>
|
||||||
|
<a-popover v-if="totals.depleted.length" :title="t('depleted')">
|
||||||
|
<template #content>
|
||||||
|
<div class="client-email-list">
|
||||||
|
<div v-for="email in totals.depleted" :key="email">{{ email }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<a-tag color="red">{{ totals.depleted.length }}</a-tag>
|
||||||
|
</a-popover>
|
||||||
|
<a-popover v-if="totals.expiring.length" :title="t('depletingSoon')">
|
||||||
|
<template #content>
|
||||||
|
<div class="client-email-list">
|
||||||
|
<div v-for="email in totals.expiring" :key="email">{{ email }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<a-tag color="orange">{{ totals.expiring.length }}</a-tag>
|
||||||
|
</a-popover>
|
||||||
|
<a-popover v-if="totals.online.length" :title="t('online')">
|
||||||
|
<template #content>
|
||||||
|
<div class="client-email-list">
|
||||||
|
<div v-for="email in totals.online" :key="email">{{ email }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<a-tag color="blue">{{ totals.online.length }}</a-tag>
|
||||||
|
</a-popover>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- Inbound list — toolbar, search/filter, columns, row actions -->
|
||||||
|
<a-col :span="24">
|
||||||
|
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
|
||||||
|
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
|
||||||
|
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
|
||||||
|
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh"
|
||||||
|
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
|
||||||
|
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
|
||||||
|
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
|
||||||
|
@delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-spin>
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
|
||||||
|
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" @saved="refresh" />
|
||||||
|
<ClientFormModal v-model:open="clientOpen" :mode="clientMode" :db-inbound="clientDbInbound"
|
||||||
|
:client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
|
||||||
|
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
|
||||||
|
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
|
||||||
|
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
|
||||||
|
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
|
||||||
|
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
||||||
|
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
|
||||||
|
:last-online-map="lastOnlineMap" :node-address="infoNodeAddress" />
|
||||||
|
<QrCodeModal v-model:open="qrOpen" :db-inbound="qrDbInbound" :client="qrClient" :remark-model="remarkModel"
|
||||||
|
:node-address="qrNodeAddress" :sub-settings="subSettings" />
|
||||||
|
|
||||||
|
<TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" />
|
||||||
|
<PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType"
|
||||||
|
:initial-value="promptInitial" :loading="promptLoading" @confirm="onPromptConfirm" />
|
||||||
|
</a-layout>
|
||||||
|
</a-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.inbounds-page {
|
||||||
|
--bg-page: #e6e8ec;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbounds-page.is-dark {
|
||||||
|
--bg-page: #1e1e1e;
|
||||||
|
--bg-card: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbounds-page.is-dark.is-ultra {
|
||||||
|
--bg-page: #050505;
|
||||||
|
--bg-card: #0c0e12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbounds-page :deep(.ant-layout),
|
||||||
|
.inbounds-page :deep(.ant-layout-content) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-shell {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content-area {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spacer {
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.summary-card {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* AD-Vue popovers teleport their content to <body>, so scoped styles
|
||||||
|
don't reach them — this block has to be unscoped. */
|
||||||
|
.client-email-list {
|
||||||
|
max-height: 280px;
|
||||||
|
min-width: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-email-list > div {
|
||||||
|
padding: 2px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { Protocols } from '@/models/inbound.js';
|
||||||
|
import QrPanel from './QrPanel.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
dbInbound: { type: Object, default: null },
|
||||||
|
client: { type: Object, default: null },
|
||||||
|
remarkModel: { type: String, default: '-ieo' },
|
||||||
|
nodeAddress: { type: String, default: '' },
|
||||||
|
subSettings: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open']);
|
||||||
|
|
||||||
|
const links = ref([]);
|
||||||
|
const wireguardConfigs = ref([]);
|
||||||
|
const wireguardLinks = ref([]);
|
||||||
|
const subLink = ref('');
|
||||||
|
const subJsonLink = ref('');
|
||||||
|
const activeKeys = ref([]);
|
||||||
|
|
||||||
|
const qrItems = computed(() => {
|
||||||
|
const items = [];
|
||||||
|
if (subLink.value) {
|
||||||
|
items.push({
|
||||||
|
key: 'sub',
|
||||||
|
header: t('subscription.title'),
|
||||||
|
value: subLink.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (subJsonLink.value) {
|
||||||
|
items.push({
|
||||||
|
key: 'sub-json',
|
||||||
|
header: `${t('subscription.title')} (JSON)`,
|
||||||
|
value: subJsonLink.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
links.value.forEach((link, idx) => {
|
||||||
|
items.push({
|
||||||
|
key: `l${idx}`,
|
||||||
|
header: link.remark || `Link ${idx + 1}`,
|
||||||
|
value: link.link,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
wireguardConfigs.value.forEach((cfg, idx) => {
|
||||||
|
items.push({
|
||||||
|
key: `wc${idx}`,
|
||||||
|
header: `Peer ${idx + 1} config`,
|
||||||
|
value: cfg,
|
||||||
|
downloadName: `peer-${idx + 1}.conf`,
|
||||||
|
});
|
||||||
|
if (wireguardLinks.value[idx]) {
|
||||||
|
items.push({
|
||||||
|
key: `wl${idx}`,
|
||||||
|
header: `Peer ${idx + 1} link`,
|
||||||
|
value: wireguardLinks.value[idx],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (!next || !props.dbInbound) return;
|
||||||
|
const inbound = props.dbInbound.toInbound();
|
||||||
|
if (inbound.protocol === Protocols.WIREGUARD) {
|
||||||
|
const peerRemark = props.client?.email
|
||||||
|
? `${props.dbInbound.remark}-${props.client.email}`
|
||||||
|
: props.dbInbound.remark;
|
||||||
|
wireguardConfigs.value = inbound.genWireguardConfigs(peerRemark, '-ieo', props.nodeAddress).split('\r\n');
|
||||||
|
wireguardLinks.value = inbound.genWireguardLinks(peerRemark, '-ieo', props.nodeAddress).split('\r\n');
|
||||||
|
links.value = [];
|
||||||
|
} else {
|
||||||
|
// When a client is provided we generate per-client share links;
|
||||||
|
// otherwise (single-user SS) fall back to the inbound's settings.
|
||||||
|
links.value = inbound.genAllLinks(props.dbInbound.remark, props.remarkModel, props.client, props.nodeAddress);
|
||||||
|
wireguardConfigs.value = [];
|
||||||
|
wireguardLinks.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const subId = props.client?.subId;
|
||||||
|
if (props.subSettings?.enable && subId) {
|
||||||
|
subLink.value = (props.subSettings.subURI || '') + subId;
|
||||||
|
subJsonLink.value = props.subSettings.subJsonEnable
|
||||||
|
? (props.subSettings.subJsonURI || '') + subId
|
||||||
|
: '';
|
||||||
|
} else {
|
||||||
|
subLink.value = '';
|
||||||
|
subJsonLink.value = '';
|
||||||
|
}
|
||||||
|
const open = [];
|
||||||
|
if (subLink.value) open.push('sub');
|
||||||
|
if (subJsonLink.value) open.push('sub-json');
|
||||||
|
activeKeys.value = open;
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="t('qrCode')" :footer="null" width="420px" @cancel="close">
|
||||||
|
<template v-if="dbInbound">
|
||||||
|
<a-collapse v-model:active-key="activeKeys" ghost class="qr-collapse">
|
||||||
|
<a-collapse-panel v-for="item in qrItems" :key="item.key" :header="item.header">
|
||||||
|
<QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''" />
|
||||||
|
</a-collapse-panel>
|
||||||
|
</a-collapse>
|
||||||
|
</template>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.qr-collapse :deep(.ant-collapse-content-box) {
|
||||||
|
padding: 8px 0 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { ClipboardManager, FileManager } from '@/utils';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: { type: String, required: true },
|
||||||
|
remark: { type: String, default: '' },
|
||||||
|
downloadName: { type: String, default: '' },
|
||||||
|
size: { type: Number, default: 240 },
|
||||||
|
showQr: { type: Boolean, default: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
const ok = await ClipboardManager.copyText(props.value);
|
||||||
|
if (ok) message.success(t('copied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
if (!props.downloadName) return;
|
||||||
|
FileManager.downloadTextFile(props.value, props.downloadName);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="qr-panel">
|
||||||
|
<div class="qr-panel-header">
|
||||||
|
<a-tag color="green" class="qr-remark">{{ remark }}</a-tag>
|
||||||
|
<a-tooltip :title="t('copy')">
|
||||||
|
<a-button size="small" @click="copy">
|
||||||
|
<template #icon>
|
||||||
|
<CopyOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip v-if="downloadName" :title="t('download')">
|
||||||
|
<a-button size="small" @click="download">
|
||||||
|
<template #icon>
|
||||||
|
<DownloadOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<div v-if="showQr" class="qr-panel-canvas">
|
||||||
|
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
|
||||||
|
:title="t('copy')" @click="copy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.qr-panel {
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-remark {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-panel-canvas {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-panel-canvas .qr-code {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 !important;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
// Loads the inbound list + sidecar data the page needs (online users,
|
||||||
|
// last-online-map, default settings) and computes the per-inbound client
|
||||||
|
// roll-ups the legacy panel surfaces in the popovers.
|
||||||
|
//
|
||||||
|
// Live-update model: initial GET on mount, then the WebSocket delta path
|
||||||
|
// keeps the table fresh — the page subscribes to the server's `traffic`,
|
||||||
|
// `client_stats`, and `invalidate` events and merges them into local
|
||||||
|
// refs in-place. The manual refresh button is kept as a fallback.
|
||||||
|
|
||||||
|
import { computed, ref, shallowRef } from 'vue';
|
||||||
|
import { HttpUtil, ObjectUtil } from '@/utils';
|
||||||
|
import { DBInbound } from '@/models/dbinbound.js';
|
||||||
|
import { Protocols } from '@/models/inbound.js';
|
||||||
|
import { setDatepicker } from '@/composables/useDatepicker.js';
|
||||||
|
|
||||||
|
export function useInbounds() {
|
||||||
|
const fetched = ref(false);
|
||||||
|
const refreshing = ref(false);
|
||||||
|
|
||||||
|
// shallowRef because each refresh swaps the array; per-row reactivity is
|
||||||
|
// unnecessary at the page level (modals work on copies).
|
||||||
|
const dbInbounds = shallowRef([]);
|
||||||
|
const clientCount = ref({});
|
||||||
|
const onlineClients = ref([]);
|
||||||
|
const lastOnlineMap = ref({});
|
||||||
|
|
||||||
|
// Default-settings sidecar fields the table needs for color/expiry math.
|
||||||
|
const expireDiff = ref(0);
|
||||||
|
const trafficDiff = ref(0);
|
||||||
|
const subSettings = ref({
|
||||||
|
enable: false,
|
||||||
|
subTitle: '',
|
||||||
|
subURI: '',
|
||||||
|
subJsonURI: '',
|
||||||
|
subJsonEnable: false,
|
||||||
|
});
|
||||||
|
const remarkModel = ref('-ieo');
|
||||||
|
const datepicker = ref('gregorian');
|
||||||
|
const tgBotEnable = ref(false);
|
||||||
|
const ipLimitEnable = ref(false);
|
||||||
|
const pageSize = ref(0);
|
||||||
|
|
||||||
|
function isClientOnline(email) {
|
||||||
|
return onlineClients.value.includes(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roll-up of {clients, active, deactive, depleted, expiring, online,
|
||||||
|
// comments} for a single inbound. Mirrors getClientCounts in the legacy
|
||||||
|
// template. Skipped for protocols that don't have multi-user clients
|
||||||
|
// (HTTP, MIXED, WireGuard) since their settings have no client list.
|
||||||
|
function rollupClients(dbInbound, inbound) {
|
||||||
|
const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
|
||||||
|
const clients = inbound?.clients || [];
|
||||||
|
const active = [];
|
||||||
|
const deactive = [];
|
||||||
|
const depleted = [];
|
||||||
|
const expiring = [];
|
||||||
|
const online = [];
|
||||||
|
const comments = new Map();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (dbInbound.enable) {
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.comment) comments.set(client.email, client.comment);
|
||||||
|
if (client.enable) {
|
||||||
|
active.push(client.email);
|
||||||
|
if (isClientOnline(client.email)) online.push(client.email);
|
||||||
|
} else {
|
||||||
|
deactive.push(client.email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const stats of clientStats) {
|
||||||
|
const exhausted = stats.total > 0 && stats.up + stats.down >= stats.total;
|
||||||
|
const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
|
||||||
|
if (expired || exhausted) {
|
||||||
|
depleted.push(stats.email);
|
||||||
|
} else {
|
||||||
|
const expiringSoon =
|
||||||
|
(stats.expiryTime > 0 && stats.expiryTime - now < expireDiff.value) ||
|
||||||
|
(stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiff.value);
|
||||||
|
if (expiringSoon) expiring.push(stats.email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const client of clients) deactive.push(client.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clients: clients.length,
|
||||||
|
active,
|
||||||
|
deactive,
|
||||||
|
depleted,
|
||||||
|
expiring,
|
||||||
|
online,
|
||||||
|
comments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInbounds(rows) {
|
||||||
|
const next = [];
|
||||||
|
const counts = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
const dbInbound = new DBInbound(row);
|
||||||
|
const parsed = dbInbound.toInbound();
|
||||||
|
next.push(dbInbound);
|
||||||
|
const tracked = [
|
||||||
|
Protocols.VMESS,
|
||||||
|
Protocols.VLESS,
|
||||||
|
Protocols.TROJAN,
|
||||||
|
Protocols.SHADOWSOCKS,
|
||||||
|
Protocols.HYSTERIA,
|
||||||
|
];
|
||||||
|
if (tracked.includes(row.protocol)) {
|
||||||
|
if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
|
||||||
|
counts[row.id] = rollupClients(dbInbound, parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dbInbounds.value = next;
|
||||||
|
clientCount.value = counts;
|
||||||
|
fetched.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOnlineUsers() {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
|
||||||
|
if (msg?.success) onlineClients.value = msg.obj || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLastOnlineMap() {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
|
||||||
|
if (msg?.success && msg.obj) lastOnlineMap.value = msg.obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDefaultSettings() {
|
||||||
|
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
||||||
|
if (!msg?.success) return;
|
||||||
|
const s = msg.obj || {};
|
||||||
|
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
|
||||||
|
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
|
||||||
|
tgBotEnable.value = !!s.tgBotEnable;
|
||||||
|
subSettings.value = {
|
||||||
|
enable: !!s.subEnable,
|
||||||
|
subTitle: s.subTitle || '',
|
||||||
|
subURI: s.subURI || '',
|
||||||
|
subJsonURI: s.subJsonURI || '',
|
||||||
|
subJsonEnable: !!s.subJsonEnable,
|
||||||
|
};
|
||||||
|
pageSize.value = s.pageSize ?? 0;
|
||||||
|
remarkModel.value = s.remarkModel || '-ieo';
|
||||||
|
datepicker.value = s.datepicker || 'gregorian';
|
||||||
|
// Mirror into the global composable so date-pickers in modals can
|
||||||
|
// pick the right calendar without re-fetching the settings.
|
||||||
|
setDatepicker(datepicker.value);
|
||||||
|
ipLimitEnable.value = !!s.ipLimitEnable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ WebSocket live-update merge ===========================
|
||||||
|
// The xray traffic job and the node traffic sync job each broadcast
|
||||||
|
// a `traffic` payload every ~10s. We merge it into onlineClients +
|
||||||
|
// lastOnlineMap; per-inbound counters arrive in the parallel
|
||||||
|
// client_stats event below.
|
||||||
|
function applyTrafficEvent(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object') return;
|
||||||
|
if (Array.isArray(payload.onlineClients)) {
|
||||||
|
onlineClients.value = payload.onlineClients;
|
||||||
|
}
|
||||||
|
if (payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
|
||||||
|
// Merge so a subsequent payload that drops a quiet client doesn't
|
||||||
|
// wipe their last-seen timestamp.
|
||||||
|
lastOnlineMap.value = { ...lastOnlineMap.value, ...payload.lastOnlineMap };
|
||||||
|
}
|
||||||
|
// Recompute per-inbound rollups so the "online" badges in the
|
||||||
|
// expand-row table flip without waiting for a full refresh.
|
||||||
|
rebuildClientCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client_stats payload carries absolute traffic counters for the
|
||||||
|
// clients that had activity in the latest window plus per-inbound
|
||||||
|
// totals. Both are absolute (not deltas), so we overwrite in place.
|
||||||
|
function applyClientStatsEvent(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object') return;
|
||||||
|
let touched = false;
|
||||||
|
|
||||||
|
if (Array.isArray(payload.inbounds) && payload.inbounds.length > 0) {
|
||||||
|
const byId = new Map();
|
||||||
|
for (const row of payload.inbounds) {
|
||||||
|
if (row && row.id != null) byId.set(row.id, row);
|
||||||
|
}
|
||||||
|
for (const ib of dbInbounds.value) {
|
||||||
|
const upd = byId.get(ib.id);
|
||||||
|
if (!upd) continue;
|
||||||
|
if (typeof upd.up === 'number') ib.up = upd.up;
|
||||||
|
if (typeof upd.down === 'number') ib.down = upd.down;
|
||||||
|
if (typeof upd.allTime === 'number') ib.allTime = upd.allTime;
|
||||||
|
if (typeof upd.total === 'number') ib.total = upd.total;
|
||||||
|
if (typeof upd.enable === 'boolean') ib.enable = upd.enable;
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload.clients) && payload.clients.length > 0) {
|
||||||
|
const byEmail = new Map();
|
||||||
|
for (const row of payload.clients) {
|
||||||
|
if (row && row.email) byEmail.set(row.email, row);
|
||||||
|
}
|
||||||
|
for (const ib of dbInbounds.value) {
|
||||||
|
if (!Array.isArray(ib.clientStats)) continue;
|
||||||
|
for (let i = 0; i < ib.clientStats.length; i++) {
|
||||||
|
const stat = ib.clientStats[i];
|
||||||
|
const upd = byEmail.get(stat.email);
|
||||||
|
if (!upd) continue;
|
||||||
|
if (typeof upd.up === 'number') stat.up = upd.up;
|
||||||
|
if (typeof upd.down === 'number') stat.down = upd.down;
|
||||||
|
if (typeof upd.total === 'number') stat.total = upd.total;
|
||||||
|
if (typeof upd.allTime === 'number') stat.allTime = upd.allTime;
|
||||||
|
if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
|
||||||
|
if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touched) {
|
||||||
|
dbInbounds.value = [...dbInbounds.value];
|
||||||
|
rebuildClientCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hub may decide a payload is too large to push directly and emit
|
||||||
|
// an `invalidate` event with the affected dataType instead. For the
|
||||||
|
// inbounds page that means "the inbound list changed elsewhere — go
|
||||||
|
// re-fetch via REST".
|
||||||
|
function applyInvalidate(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object') return;
|
||||||
|
if (payload.type === 'inbounds') {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInboundsEvent(payload) {
|
||||||
|
if (!Array.isArray(payload)) return;
|
||||||
|
setInbounds(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute the per-inbound roll-up after any in-place mutation.
|
||||||
|
// Cheap because rollupClients only iterates a single inbound's
|
||||||
|
// clients + clientStats arrays.
|
||||||
|
function rebuildClientCount() {
|
||||||
|
const counts = {};
|
||||||
|
const tracked = [
|
||||||
|
Protocols.VMESS,
|
||||||
|
Protocols.VLESS,
|
||||||
|
Protocols.TROJAN,
|
||||||
|
Protocols.SHADOWSOCKS,
|
||||||
|
Protocols.HYSTERIA,
|
||||||
|
];
|
||||||
|
for (const dbInbound of dbInbounds.value) {
|
||||||
|
const parsed = dbInbound.toInbound();
|
||||||
|
if (!tracked.includes(dbInbound.protocol)) continue;
|
||||||
|
if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
|
||||||
|
counts[dbInbound.id] = rollupClients(dbInbound, parsed);
|
||||||
|
}
|
||||||
|
clientCount.value = counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
refreshing.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/inbounds/list');
|
||||||
|
if (!msg?.success) return;
|
||||||
|
await fetchLastOnlineMap();
|
||||||
|
await fetchOnlineUsers();
|
||||||
|
setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
|
||||||
|
} finally {
|
||||||
|
// Match legacy: keep the spinning-icon state visible briefly so
|
||||||
|
// a fast network doesn't make the button feel like it didn't fire.
|
||||||
|
setTimeout(() => { refreshing.value = false; }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate totals shown in the dashboard summary card. allTime falls
|
||||||
|
// back to up+down when the per-inbound counter isn't populated yet.
|
||||||
|
const totals = computed(() => {
|
||||||
|
let up = 0;
|
||||||
|
let down = 0;
|
||||||
|
let allTime = 0;
|
||||||
|
let clients = 0;
|
||||||
|
const deactive = [];
|
||||||
|
const depleted = [];
|
||||||
|
const expiring = [];
|
||||||
|
const online = [];
|
||||||
|
for (const ib of dbInbounds.value) {
|
||||||
|
up += ib.up || 0;
|
||||||
|
down += ib.down || 0;
|
||||||
|
allTime += ib.allTime || (ib.up + ib.down) || 0;
|
||||||
|
const c = clientCount.value[ib.id];
|
||||||
|
if (c) {
|
||||||
|
clients += c.clients;
|
||||||
|
deactive.push(...c.deactive);
|
||||||
|
depleted.push(...c.depleted);
|
||||||
|
expiring.push(...c.expiring);
|
||||||
|
online.push(...c.online);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { up, down, allTime, clients, deactive, depleted, expiring, online };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ObjectUtil reference is wired at module load — keeping a no-op import
|
||||||
|
// here so the linter doesn't drop it; the legacy search uses it.
|
||||||
|
void ObjectUtil;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetched,
|
||||||
|
refreshing,
|
||||||
|
dbInbounds,
|
||||||
|
clientCount,
|
||||||
|
onlineClients,
|
||||||
|
lastOnlineMap,
|
||||||
|
totals,
|
||||||
|
expireDiff,
|
||||||
|
trafficDiff,
|
||||||
|
subSettings,
|
||||||
|
remarkModel,
|
||||||
|
datepicker,
|
||||||
|
tgBotEnable,
|
||||||
|
ipLimitEnable,
|
||||||
|
pageSize,
|
||||||
|
refresh,
|
||||||
|
fetchDefaultSettings,
|
||||||
|
applyTrafficEvent,
|
||||||
|
applyClientStatsEvent,
|
||||||
|
applyInvalidate,
|
||||||
|
applyInboundsEvent,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { DownloadOutlined, UploadOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
basePath: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'busy']);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportDb() {
|
||||||
|
// The Go endpoint streams x-ui.db as a download. Setting
|
||||||
|
// window.location triggers a browser download without leaving
|
||||||
|
// the page (the Go side responds with Content-Disposition: attachment).
|
||||||
|
window.location = window.X_UI_BASE_PATH+'panel/api/server/getDb';
|
||||||
|
}
|
||||||
|
|
||||||
|
function importDb() {
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.accept = '.db';
|
||||||
|
fileInput.addEventListener('change', async (e) => {
|
||||||
|
const dbFile = e.target.files?.[0];
|
||||||
|
if (!dbFile) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('db', dbFile);
|
||||||
|
|
||||||
|
close();
|
||||||
|
emit('busy', { busy: true, tip: t('pages.index.importDatabase') + '…' });
|
||||||
|
|
||||||
|
const upload = await HttpUtil.post('/panel/api/server/importDB', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
if (!upload?.success) {
|
||||||
|
emit('busy', { busy: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('busy', { busy: true, tip: t('pages.settings.restartPanel') + '…' });
|
||||||
|
const restart = await HttpUtil.post('/panel/setting/restartPanel');
|
||||||
|
if (restart?.success) {
|
||||||
|
await PromiseUtil.sleep(5000);
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
emit('busy', { busy: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="t('pages.index.backupTitle')" :closable="true" :footer="null" @cancel="close">
|
||||||
|
<a-list bordered class="backup-list">
|
||||||
|
<a-list-item class="backup-item">
|
||||||
|
<a-list-item-meta>
|
||||||
|
<template #title>{{ t('pages.index.exportDatabase') }}</template>
|
||||||
|
<template #description>{{ t('pages.index.exportDatabaseDesc') }}</template>
|
||||||
|
</a-list-item-meta>
|
||||||
|
<a-button type="primary" @click="exportDb">
|
||||||
|
<template #icon>
|
||||||
|
<DownloadOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-list-item>
|
||||||
|
|
||||||
|
<a-list-item class="backup-item">
|
||||||
|
<a-list-item-meta>
|
||||||
|
<template #title>{{ t('pages.index.importDatabase') }}</template>
|
||||||
|
<template #description>{{ t('pages.index.importDatabaseDesc') }}</template>
|
||||||
|
</a-list-item-meta>
|
||||||
|
<a-button type="primary" @click="importDb">
|
||||||
|
<template #icon>
|
||||||
|
<UploadOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-list-item>
|
||||||
|
</a-list>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backup-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
// Populate with the record when editing; null/undefined when adding.
|
||||||
|
record: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'saved']);
|
||||||
|
|
||||||
|
const form = reactive({ type: 'geosite', alias: '', url: '' });
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const editing = ref(false);
|
||||||
|
const editId = ref(null);
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (!next) return;
|
||||||
|
if (props.record) {
|
||||||
|
editing.value = true;
|
||||||
|
editId.value = props.record.id;
|
||||||
|
form.type = props.record.type;
|
||||||
|
form.alias = props.record.alias;
|
||||||
|
form.url = props.record.url;
|
||||||
|
} else {
|
||||||
|
editing.value = false;
|
||||||
|
editId.value = null;
|
||||||
|
form.type = 'geosite';
|
||||||
|
form.alias = '';
|
||||||
|
form.url = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
// Backend expects a filesystem-safe alias; legacy enforces the same regex.
|
||||||
|
if (!/^[a-z0-9_-]+$/.test(form.alias || '')) {
|
||||||
|
message.error(t('pages.index.customGeoValidationAlias'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const u = (form.url || '').trim();
|
||||||
|
if (!/^https?:\/\//i.test(u)) {
|
||||||
|
message.error(t('pages.index.customGeoValidationUrl'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(u);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
message.error(t('pages.index.customGeoValidationUrl'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
message.error(t('pages.index.customGeoValidationUrl'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!validate()) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const url = editing.value
|
||||||
|
? `/panel/api/custom-geo/update/${editId.value}`
|
||||||
|
: '/panel/api/custom-geo/add';
|
||||||
|
const msg = await HttpUtil.post(url, form);
|
||||||
|
if (msg?.success) {
|
||||||
|
emit('saved');
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')"
|
||||||
|
:confirm-loading="saving" :ok-text="t('pages.index.customGeoModalSave')" :cancel-text="t('close')" @ok="submit"
|
||||||
|
@cancel="close">
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item :label="t('pages.index.customGeoType')">
|
||||||
|
<a-select v-model:value="form.type" :disabled="editing">
|
||||||
|
<a-select-option value="geosite">geosite</a-select-option>
|
||||||
|
<a-select-option value="geoip">geoip</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label="t('pages.index.customGeoAlias')">
|
||||||
|
<a-input v-model:value="form.alias" :disabled="editing"
|
||||||
|
:placeholder="t('pages.index.customGeoAliasPlaceholder')" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label="t('pages.index.customGeoUrl')">
|
||||||
|
<a-input v-model:value="form.url" placeholder="https://" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Modal, message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import { HttpUtil, ClipboardManager } from '@/utils';
|
||||||
|
import CustomGeoFormModal from './CustomGeoFormModal.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// Re-fetch the list when the parent collapse expands this section.
|
||||||
|
active: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const updatingAll = ref(false);
|
||||||
|
const actionId = ref(null);
|
||||||
|
|
||||||
|
const formOpen = ref(false);
|
||||||
|
const editingRecord = ref(null);
|
||||||
|
|
||||||
|
// Computed so column titles re-render after a locale swap.
|
||||||
|
const columns = computed(() => [
|
||||||
|
{ title: t('pages.index.customGeoAlias'), key: 'alias', width: 200 },
|
||||||
|
{ title: t('pages.index.customGeoUrl'), key: 'url', ellipsis: true },
|
||||||
|
{ title: t('pages.index.customGeoExtColumn'), key: 'extDat', width: 220 },
|
||||||
|
{ title: t('pages.index.customGeoLastUpdated'), key: 'lastUpdatedAt', width: 140 },
|
||||||
|
{ title: t('pages.index.customGeoActions'), key: 'action', width: 120 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/custom-geo/list');
|
||||||
|
if (msg?.success && Array.isArray(msg.obj)) list.value = msg.obj;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
editingRecord.value = null;
|
||||||
|
formOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(record) {
|
||||||
|
editingRecord.value = record;
|
||||||
|
formOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extDisplay(record) {
|
||||||
|
const fn = record.type === 'geoip'
|
||||||
|
? `geoip_${record.alias}.dat`
|
||||||
|
: `geosite_${record.alias}.dat`;
|
||||||
|
return `ext:${fn}:tag`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyExt(record) {
|
||||||
|
const text = extDisplay(record);
|
||||||
|
const ok = await ClipboardManager.copyText(text);
|
||||||
|
if (ok) message.success(`${t('copied')}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
if (!ts) return '';
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
if (isNaN(d.getTime())) return String(ts);
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiny inline relative-time formatter so we don't pull in moment.
|
||||||
|
function relativeTime(ts) {
|
||||||
|
if (!ts) return '';
|
||||||
|
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
|
||||||
|
if (diff < 2592000) return `${Math.floor(diff / 86400)} d ago`;
|
||||||
|
return formatTime(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(record) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('pages.index.customGeoDelete'),
|
||||||
|
content: t('pages.index.customGeoDeleteConfirm'),
|
||||||
|
okText: t('delete'),
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
|
||||||
|
if (msg?.success) await loadList();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadOne(id) {
|
||||||
|
actionId.value = id;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
|
||||||
|
if (msg?.success) await loadList();
|
||||||
|
} finally {
|
||||||
|
actionId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAll() {
|
||||||
|
updatingAll.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
|
||||||
|
const ok = msg?.obj?.succeeded?.length || 0;
|
||||||
|
const failed = msg?.obj?.failed?.length || 0;
|
||||||
|
if (msg?.success || ok > 0) {
|
||||||
|
await loadList();
|
||||||
|
if (failed > 0) message.warning(`Updated ${ok}, failed ${failed}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
updatingAll.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy-load: only fetch when the parent collapse opens this panel.
|
||||||
|
watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="custom-geo-section">
|
||||||
|
<a-alert type="info" show-icon class="mb-10" :message="t('pages.index.customGeoRoutingHint')" />
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<a-button type="primary" :loading="loading" @click="openAdd">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
{{ t('pages.index.customGeoAdd') }}
|
||||||
|
</a-button>
|
||||||
|
<a-button :loading="updatingAll" :disabled="!list.length" @click="updateAll">
|
||||||
|
<template #icon>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</template>
|
||||||
|
{{ t('pages.index.geofilesUpdateAll') }}
|
||||||
|
</a-button>
|
||||||
|
<span v-if="list.length" class="custom-geo-count">{{ list.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-table :columns="columns" :data-source="list" :pagination="false" :row-key="(r) => r.id" :loading="loading"
|
||||||
|
size="small" :scroll="{ x: 760 }">
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'alias'">
|
||||||
|
<div class="custom-geo-alias-cell">
|
||||||
|
<a-tag :color="record.type === 'geoip' ? 'cyan' : 'purple'" class="custom-geo-type-tag">
|
||||||
|
{{ record.type }}
|
||||||
|
</a-tag>
|
||||||
|
<span class="custom-geo-alias">{{ record.alias }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'url'">
|
||||||
|
<a-tooltip placement="topLeft" :title="record.url">
|
||||||
|
<a :href="record.url" target="_blank" rel="noopener noreferrer" class="custom-geo-url">
|
||||||
|
{{ record.url }}
|
||||||
|
</a>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'extDat'">
|
||||||
|
<a-tooltip :title="t('copy')">
|
||||||
|
<code class="custom-geo-ext-code custom-geo-copyable" @click="copyExt(record)">
|
||||||
|
{{ extDisplay(record) }}
|
||||||
|
</code>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'lastUpdatedAt'">
|
||||||
|
<a-tooltip v-if="record.lastUpdatedAt" :title="formatTime(record.lastUpdatedAt)">
|
||||||
|
<span>{{ relativeTime(record.lastUpdatedAt) }}</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-else class="custom-geo-muted">—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<a-space size="small">
|
||||||
|
<a-tooltip :title="t('pages.index.customGeoEdit')">
|
||||||
|
<a-button type="link" size="small" @click="openEdit(record)">
|
||||||
|
<template #icon>
|
||||||
|
<EditOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :title="t('pages.index.customGeoDownload')">
|
||||||
|
<a-button type="link" size="small" :loading="actionId === record.id" @click="downloadOne(record.id)">
|
||||||
|
<template #icon>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :title="t('pages.index.customGeoDelete')">
|
||||||
|
<a-button type="link" size="small" danger @click="confirmDelete(record)">
|
||||||
|
<template #icon>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #emptyText>
|
||||||
|
<div class="custom-geo-empty">
|
||||||
|
<InboxOutlined class="custom-geo-empty-icon" />
|
||||||
|
<div>{{ t('pages.index.customGeoEmpty') }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<CustomGeoFormModal v-model:open="formOpen" :record="editingRecord" @saved="loadList" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mb-10 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-count {
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .custom-geo-count {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-alias-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-alias {
|
||||||
|
font-weight: 500;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-type-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-url {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-ext-code {
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-copyable:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .custom-geo-ext-code {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .custom-geo-copyable:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-muted {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 18px 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-geo-empty-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import {
|
||||||
|
BarsOutlined,
|
||||||
|
ControlOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
CloudDownloadOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
|
ArrowUpOutlined,
|
||||||
|
ArrowDownOutlined,
|
||||||
|
AreaChartOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
SwapOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
EyeInvisibleOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
import { HttpUtil, SizeFormatter, TimeFormatter } from '@/utils';
|
||||||
|
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||||
|
import { useStatus } from '@/composables/useStatus.js';
|
||||||
|
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||||
|
import AppSidebar from '@/components/AppSidebar.vue';
|
||||||
|
import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||||
|
import TextModal from '@/components/TextModal.vue';
|
||||||
|
import StatusCard from './StatusCard.vue';
|
||||||
|
import XrayStatusCard from './XrayStatusCard.vue';
|
||||||
|
import PanelUpdateModal from './PanelUpdateModal.vue';
|
||||||
|
import LogModal from './LogModal.vue';
|
||||||
|
import BackupModal from './BackupModal.vue';
|
||||||
|
import SystemHistoryModal from './SystemHistoryModal.vue';
|
||||||
|
import XrayLogModal from './XrayLogModal.vue';
|
||||||
|
import VersionModal from './VersionModal.vue';
|
||||||
|
|
||||||
|
const { status, fetched, refresh } = useStatus();
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
|
||||||
|
// `/panel/setting/defaultSettings` returns ipLimitEnable; the xray
|
||||||
|
// card hides its log button when access logs are off.
|
||||||
|
const ipLimitEnable = ref(false);
|
||||||
|
HttpUtil.post('/panel/setting/defaultSettings').then((msg) => {
|
||||||
|
if (msg?.success && msg.obj) ipLimitEnable.value = !!msg.obj.ipLimitEnable;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Panel-update info — fetched once on mount, drives both the badge
|
||||||
|
// in QuickActions and the contents of PanelUpdateModal.
|
||||||
|
const panelUpdateInfo = ref({ currentVersion: '', latestVersion: '', updateAvailable: false });
|
||||||
|
onMounted(() => {
|
||||||
|
HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
||||||
|
if (msg?.success && msg.obj) panelUpdateInfo.value = msg.obj;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
|
const requestUri = window.location.pathname;
|
||||||
|
|
||||||
|
// In production, dist.go injects window.X_UI_CUR_VER at serve time.
|
||||||
|
// In dev, Vite serves the HTML directly so the global is missing — fall
|
||||||
|
// back to currentVersion from the panel-update API once it answers.
|
||||||
|
const displayVersion = computed(
|
||||||
|
() => panelUpdateInfo.value?.currentVersion || window.X_UI_CUR_VER || '?',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hide/reveal the public IPv4/IPv6 — same pattern as legacy.
|
||||||
|
const showIp = ref(false);
|
||||||
|
|
||||||
|
// Modal open state.
|
||||||
|
const logsOpen = ref(false);
|
||||||
|
const backupOpen = ref(false);
|
||||||
|
const panelUpdateOpen = ref(false);
|
||||||
|
const sysHistoryOpen = ref(false);
|
||||||
|
const xrayLogsOpen = ref(false);
|
||||||
|
const versionOpen = ref(false);
|
||||||
|
const configTextOpen = ref(false);
|
||||||
|
const configText = ref('');
|
||||||
|
|
||||||
|
// Page-level loading overlay; modals can request it via @busy.
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadingTip = ref(t('loading'));
|
||||||
|
function setBusy({ busy, tip }) {
|
||||||
|
loading.value = busy;
|
||||||
|
if (tip) loadingTip.value = tip;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Xray controls
|
||||||
|
async function stopXray() {
|
||||||
|
await HttpUtil.post('/panel/api/server/stopXrayService');
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
async function restartXray() {
|
||||||
|
await HttpUtil.post('/panel/api/server/restartXrayService');
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSystemHistory() { sysHistoryOpen.value = true; }
|
||||||
|
function openXrayLogs() { xrayLogsOpen.value = true; }
|
||||||
|
function openVersionSwitch() { versionOpen.value = true; }
|
||||||
|
|
||||||
|
// Legacy "Config" action — fetch the rendered xray config and show
|
||||||
|
// it as JSON in the shared TextModal (same UX as main).
|
||||||
|
async function openConfig() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
|
||||||
|
if (!msg?.success) return;
|
||||||
|
configText.value = JSON.stringify(msg.obj, null, 2);
|
||||||
|
configTextOpen.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-config-provider :theme="antdThemeConfig">
|
||||||
|
<a-layout class="index-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||||
|
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||||
|
|
||||||
|
<a-layout class="content-shell">
|
||||||
|
<a-layout-content class="content-area">
|
||||||
|
<a-spin :spinning="loading || !fetched" :delay="200" :tip="loading ? loadingTip : t('loading')" size="large">
|
||||||
|
<div v-if="!fetched" class="loading-spacer" />
|
||||||
|
|
||||||
|
<a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
|
||||||
|
<a-col :span="24">
|
||||||
|
<StatusCard :status="status" :is-mobile="isMobile" />
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<XrayStatusCard :status="status" :is-mobile="isMobile" :ip-limit-enable="ipLimitEnable"
|
||||||
|
@stop-xray="stopXray" @restart-xray="restartXray" @open-xray-logs="openXrayLogs"
|
||||||
|
@open-logs="logsOpen = true" @open-version-switch="openVersionSwitch" />
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-card :title="t('menu.link')" hoverable>
|
||||||
|
<template #actions>
|
||||||
|
<a-space class="action" @click="logsOpen = true">
|
||||||
|
<BarsOutlined />
|
||||||
|
<span v-if="!isMobile">{{ t('pages.index.logs') }}</span>
|
||||||
|
</a-space>
|
||||||
|
<a-space class="action" @click="openConfig">
|
||||||
|
<ControlOutlined />
|
||||||
|
<span v-if="!isMobile">{{ t('pages.index.config') }}</span>
|
||||||
|
</a-space>
|
||||||
|
<a-space class="action" @click="backupOpen = true">
|
||||||
|
<CloudServerOutlined />
|
||||||
|
<span v-if="!isMobile">{{ t('pages.index.backupTitle') }}</span>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-card title="3X-UI" hoverable>
|
||||||
|
<template v-if="panelUpdateInfo.updateAvailable" #extra>
|
||||||
|
<a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`">
|
||||||
|
<a-tag color="orange" class="update-tag" @click="panelUpdateOpen = true">
|
||||||
|
<CloudDownloadOutlined />
|
||||||
|
{{ panelUpdateInfo.latestVersion }}
|
||||||
|
<span v-if="!isMobile">{{ t('pages.index.updatePanel') }}</span>
|
||||||
|
</a-tag>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<div class="link-tags">
|
||||||
|
<a href="https://github.com/MHSanaei/3x-ui/releases" target="_blank" rel="noopener noreferrer">
|
||||||
|
<a-tag color="green">v{{ displayVersion }}</a-tag>
|
||||||
|
</a>
|
||||||
|
<a href="https://t.me/XrayUI" target="_blank" rel="noopener noreferrer">
|
||||||
|
<a-tag color="green">@XrayUI</a-tag>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/MHSanaei/3x-ui/wiki" target="_blank" rel="noopener noreferrer">
|
||||||
|
<a-tag color="purple">{{ t('pages.index.documentation') }}</a-tag>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-card :title="t('pages.index.operationHours')" hoverable>
|
||||||
|
<a-tag :color="status.xray.color">
|
||||||
|
Xray: {{ TimeFormatter.formatSecond(status.appStats.uptime) }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag color="green">OS: {{ TimeFormatter.formatSecond(status.uptime) }}</a-tag>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-card :title="t('pages.index.systemLoad')" hoverable>
|
||||||
|
<template #extra>
|
||||||
|
<a-tag color="blue" class="history-tag" @click="openSystemHistory">
|
||||||
|
<AreaChartOutlined />
|
||||||
|
{{ t('pages.index.systemHistoryTitle') }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<a-tooltip :title="t('pages.index.systemLoadDesc')">
|
||||||
|
<a-tag color="green">
|
||||||
|
{{ status.loads[0] }} | {{ status.loads[1] }} | {{ status.loads[2] }}
|
||||||
|
</a-tag>
|
||||||
|
</a-tooltip>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-card :title="t('usage')" hoverable>
|
||||||
|
<a-tag color="green">
|
||||||
|
{{ t('pages.index.memory') }}: {{ SizeFormatter.sizeFormat(status.appStats.mem) }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag color="green">
|
||||||
|
{{ t('pages.index.threads') }}: {{ status.appStats.threads }}
|
||||||
|
</a-tag>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-card :title="t('pages.index.overallSpeed')" hoverable>
|
||||||
|
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||||
|
<a-col :span="12">
|
||||||
|
<CustomStatistic :title="t('pages.index.upload')"
|
||||||
|
:value="SizeFormatter.sizeFormat(status.netIO.up)">
|
||||||
|
<template #prefix>
|
||||||
|
<ArrowUpOutlined />
|
||||||
|
</template>
|
||||||
|
<template #suffix>/s</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<CustomStatistic :title="t('pages.index.download')"
|
||||||
|
:value="SizeFormatter.sizeFormat(status.netIO.down)">
|
||||||
|
<template #prefix>
|
||||||
|
<ArrowDownOutlined />
|
||||||
|
</template>
|
||||||
|
<template #suffix>/s</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-card :title="t('pages.index.totalData')" hoverable>
|
||||||
|
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||||
|
<a-col :span="12">
|
||||||
|
<CustomStatistic :title="t('pages.index.sent')"
|
||||||
|
:value="SizeFormatter.sizeFormat(status.netTraffic.sent)">
|
||||||
|
<template #prefix>
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<CustomStatistic :title="t('pages.index.received')"
|
||||||
|
:value="SizeFormatter.sizeFormat(status.netTraffic.recv)">
|
||||||
|
<template #prefix>
|
||||||
|
<CloudDownloadOutlined />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-card :title="t('pages.index.ipAddresses')" hoverable>
|
||||||
|
<template #extra>
|
||||||
|
<a-tooltip :title="t('pages.index.toggleIpVisibility')" :placement="isMobile ? 'topRight' : 'top'">
|
||||||
|
<component :is="showIp ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
|
||||||
|
@click="showIp = !showIp" />
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-row :class="showIp ? 'ip-visible' : 'ip-hidden'" :gutter="isMobile ? [8, 8] : 0">
|
||||||
|
<a-col :span="isMobile ? 24 : 12">
|
||||||
|
<CustomStatistic title="IPv4" :value="status.publicIP.ipv4">
|
||||||
|
<template #prefix>
|
||||||
|
<GlobalOutlined />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="isMobile ? 24 : 12">
|
||||||
|
<CustomStatistic title="IPv6" :value="status.publicIP.ipv6">
|
||||||
|
<template #prefix>
|
||||||
|
<GlobalOutlined />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-card :title="t('pages.index.connectionCount')" hoverable>
|
||||||
|
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||||
|
<a-col :span="12">
|
||||||
|
<CustomStatistic title="TCP" :value="status.tcpCount">
|
||||||
|
<template #prefix>
|
||||||
|
<SwapOutlined />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<CustomStatistic title="UDP" :value="status.udpCount">
|
||||||
|
<template #prefix>
|
||||||
|
<SwapOutlined />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-spin>
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
|
||||||
|
<PanelUpdateModal v-model:open="panelUpdateOpen" :info="panelUpdateInfo" @busy="setBusy" />
|
||||||
|
<LogModal v-model:open="logsOpen" />
|
||||||
|
<BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
|
||||||
|
<SystemHistoryModal v-model:open="sysHistoryOpen" :status="status" />
|
||||||
|
<XrayLogModal v-model:open="xrayLogsOpen" />
|
||||||
|
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
|
||||||
|
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
|
||||||
|
file-name="config.json" />
|
||||||
|
</a-layout>
|
||||||
|
</a-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.index-page {
|
||||||
|
--bg-page: #e6e8ec;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-page.is-dark {
|
||||||
|
--bg-page: #1e1e1e;
|
||||||
|
--bg-card: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-page.is-dark.is-ultra {
|
||||||
|
--bg-page: #050505;
|
||||||
|
--bg-card: #0c0e12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-page :deep(.ant-layout),
|
||||||
|
.index-page :deep(.ant-layout-content) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-shell {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content-area {
|
||||||
|
padding: 12px;
|
||||||
|
padding-top: 64px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spacer {
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
cursor: pointer;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-tag {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tag {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-tags a {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-tags :deep(.ant-tag) {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-toggle-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-hidden :deep(.ant-statistic-content-value) {
|
||||||
|
filter: blur(6px);
|
||||||
|
transition: filter 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-visible :deep(.ant-statistic-content-value) {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
|
||||||
|
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open']);
|
||||||
|
|
||||||
|
const rows = ref('20');
|
||||||
|
const level = ref('info');
|
||||||
|
const syslog = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const logs = ref([]);
|
||||||
|
|
||||||
|
const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
|
||||||
|
const LEVEL_CLASSES = ['level-debug', 'level-info', 'level-notice', 'level-warning', 'level-error'];
|
||||||
|
|
||||||
|
// Parses "YYYY-MM-DD HH:MM:SS LEVEL - message". Lines without the
|
||||||
|
// 3-token header degrade gracefully: the unparsed head becomes the
|
||||||
|
// level so it still gets color-coded.
|
||||||
|
function parseLogLine(line) {
|
||||||
|
const [head, ...rest] = (line || '').split(' - ');
|
||||||
|
const message = rest.join(' - ');
|
||||||
|
const parts = head.split(' ');
|
||||||
|
|
||||||
|
let date = '';
|
||||||
|
let time = '';
|
||||||
|
let levelText;
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
[date, time, levelText] = parts;
|
||||||
|
} else {
|
||||||
|
levelText = head;
|
||||||
|
}
|
||||||
|
|
||||||
|
const li = LEVELS.indexOf(levelText);
|
||||||
|
const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
|
||||||
|
|
||||||
|
let service = '';
|
||||||
|
let body = message || '';
|
||||||
|
if (body.startsWith('XRAY:')) {
|
||||||
|
service = 'XRAY:';
|
||||||
|
body = body.slice('XRAY:'.length).trimStart();
|
||||||
|
} else if (body) {
|
||||||
|
service = 'X-UI:';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { date, time, levelText, levelClass, service, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedLogs = computed(() => logs.value.map(parseLogLine));
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/server/logs/${rows.value}`, {
|
||||||
|
level: level.value,
|
||||||
|
syslog: syslog.value,
|
||||||
|
});
|
||||||
|
if (msg?.success) {
|
||||||
|
logs.value = msg.obj || [];
|
||||||
|
}
|
||||||
|
await PromiseUtil.sleep(300);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
FileManager.downloadTextFile(logs.value.join('\n'), 'x-ui.log');
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => { if (next) refresh(); });
|
||||||
|
watch([rows, level, syslog], () => { if (props.open) refresh(); });
|
||||||
|
|
||||||
|
const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" :class="{ 'logmodal-mobile': isMobile }"
|
||||||
|
@cancel="close">
|
||||||
|
<template #title>
|
||||||
|
{{ t('pages.index.logs') }}
|
||||||
|
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-form layout="inline" class="log-toolbar">
|
||||||
|
<a-form-item>
|
||||||
|
<a-input-group compact>
|
||||||
|
<a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
|
||||||
|
<a-select-option value="10">10</a-select-option>
|
||||||
|
<a-select-option value="20">20</a-select-option>
|
||||||
|
<a-select-option value="50">50</a-select-option>
|
||||||
|
<a-select-option value="100">100</a-select-option>
|
||||||
|
<a-select-option value="500">500</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<a-select v-model:value="level" size="small" :style="{ width: '95px' }">
|
||||||
|
<a-select-option value="debug">Debug</a-select-option>
|
||||||
|
<a-select-option value="info">Info</a-select-option>
|
||||||
|
<a-select-option value="notice">Notice</a-select-option>
|
||||||
|
<a-select-option value="warning">Warning</a-select-option>
|
||||||
|
<a-select-option value="err">Error</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-input-group>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-checkbox v-model:checked="syslog">SysLog</a-checkbox>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item class="download-item">
|
||||||
|
<a-button type="primary" @click="download">
|
||||||
|
<template #icon>
|
||||||
|
<DownloadOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<div class="log-container" :class="{ 'log-container-mobile': isMobile }">
|
||||||
|
<div v-if="parsedLogs.length === 0" class="log-empty">No Record...</div>
|
||||||
|
|
||||||
|
<template v-else-if="isMobile">
|
||||||
|
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-card">
|
||||||
|
<div class="log-card-head">
|
||||||
|
<span v-if="log.date || log.time" class="log-time">
|
||||||
|
<span v-if="log.time">{{ log.time }}</span>
|
||||||
|
<span v-if="log.date" class="log-date">{{ log.date }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="log.levelText" class="log-level-badge" :class="log.levelClass">
|
||||||
|
{{ log.levelText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="log.body || log.service" class="log-body">
|
||||||
|
<b v-if="log.service">{{ log.service }}</b>
|
||||||
|
<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-line">
|
||||||
|
<span v-if="log.date || log.time" class="log-stamp">
|
||||||
|
{{ log.date }}<template v-if="log.date && log.time"> </template>{{ log.time }}
|
||||||
|
</span>
|
||||||
|
<span v-if="log.levelText" class="log-level" :class="log.levelClass">
|
||||||
|
{{ log.levelText }}
|
||||||
|
</span>
|
||||||
|
<template v-if="log.body || log.service">
|
||||||
|
<span> - </span>
|
||||||
|
<b v-if="log.service">{{ log.service }} </b>
|
||||||
|
<span>{{ log.body }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reload-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toolbar .download-item {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
/* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
|
||||||
|
below so each level keeps ≥4.5:1 contrast against the container. */
|
||||||
|
--log-stamp: #3c89e8;
|
||||||
|
--log-debug: #3c89e8;
|
||||||
|
--log-info: #008771;
|
||||||
|
--log-notice: #008771;
|
||||||
|
--log-warning: #f37b24;
|
||||||
|
--log-error: #e04141;
|
||||||
|
--log-unknown: #595959;
|
||||||
|
--log-divider: rgba(128, 128, 128, 0.18);
|
||||||
|
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-stamp {
|
||||||
|
color: var(--log-stamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-debug {
|
||||||
|
color: var(--log-debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-info {
|
||||||
|
color: var(--log-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-notice {
|
||||||
|
color: var(--log-notice);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-warning {
|
||||||
|
color: var(--log-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-error {
|
||||||
|
color: var(--log-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-unknown {
|
||||||
|
color: var(--log-unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container-mobile {
|
||||||
|
padding: 8px;
|
||||||
|
white-space: normal;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-empty {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.5;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line+.log-line {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-card {
|
||||||
|
border-bottom: 1px solid var(--log-divider);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-card:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-date {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 14px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: color-mix(in srgb, currentColor 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-body {
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-body-text {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .log-container {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
|
||||||
|
--log-stamp: #6aa6ee;
|
||||||
|
--log-debug: #6aa6ee;
|
||||||
|
--log-info: #4ed3a6;
|
||||||
|
--log-notice: #4ed3a6;
|
||||||
|
--log-warning: #ffb872;
|
||||||
|
--log-error: #ff7575;
|
||||||
|
--log-unknown: #b5b5b5;
|
||||||
|
--log-divider: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="ultra-dark"]) .log-container {
|
||||||
|
--log-stamp: #7fb6f1;
|
||||||
|
--log-debug: #7fb6f1;
|
||||||
|
--log-info: #5fd9b0;
|
||||||
|
--log-notice: #5fd9b0;
|
||||||
|
--log-warning: #ffcc88;
|
||||||
|
--log-error: #ff8a8a;
|
||||||
|
--log-unknown: #c4c4c4;
|
||||||
|
--log-divider: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: pull the modal flush with the screen edges. */
|
||||||
|
:global(.logmodal-mobile) {
|
||||||
|
top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.logmodal-mobile .ant-modal-content) {
|
||||||
|
border-radius: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.logmodal-mobile .ant-modal-body) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Modal, message } from 'ant-design-vue';
|
||||||
|
import { CloudDownloadOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
info: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ currentVersion: '', latestVersion: '', updateAvailable: false }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'busy']);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePanel() {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('pages.index.panelUpdateDialog'),
|
||||||
|
content: t('pages.index.panelUpdateDialogDesc').replace('#version#', props.info.latestVersion || ''),
|
||||||
|
okText: t('confirm'),
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: async () => {
|
||||||
|
const baseTip = t('pages.index.dontRefresh');
|
||||||
|
const tip = props.info.latestVersion ? `${baseTip} (${props.info.latestVersion})` : baseTip;
|
||||||
|
close();
|
||||||
|
emit('busy', { busy: true, tip });
|
||||||
|
const msg = await HttpUtil.post('/panel/api/server/updatePanel');
|
||||||
|
if (!msg?.success) {
|
||||||
|
emit('busy', { busy: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Wait for the running process to exit, then poll the new panel
|
||||||
|
// until it answers (up to ~90s). Reload as soon as it's back.
|
||||||
|
await PromiseUtil.sleep(5000);
|
||||||
|
const deadline = Date.now() + 90_000;
|
||||||
|
let back = false;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const r = await axios.get('/panel/api/server/status', { timeout: 2000 });
|
||||||
|
if (r?.data?.success) { back = true; break; }
|
||||||
|
} catch (_) { /* still restarting */ }
|
||||||
|
await PromiseUtil.sleep(2000);
|
||||||
|
}
|
||||||
|
if (back) {
|
||||||
|
message.success(t('pages.index.panelUpdateStartedPopover'));
|
||||||
|
await PromiseUtil.sleep(800);
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="t('pages.index.updatePanel')" :closable="true" :footer="null" @cancel="close">
|
||||||
|
<a-alert v-if="info.updateAvailable" type="warning" class="mb-12" :message="t('pages.index.panelUpdateDesc')"
|
||||||
|
show-icon />
|
||||||
|
|
||||||
|
<a-list bordered class="version-list">
|
||||||
|
<a-list-item class="version-list-item">
|
||||||
|
<span>{{ t('pages.index.currentPanelVersion') }}</span>
|
||||||
|
<a-tag color="green">v{{ info.currentVersion || '?' }}</a-tag>
|
||||||
|
</a-list-item>
|
||||||
|
<a-list-item v-if="info.updateAvailable" class="version-list-item">
|
||||||
|
<span>{{ t('pages.index.latestPanelVersion') }}</span>
|
||||||
|
<a-tag color="purple">{{ info.latestVersion || '-' }}</a-tag>
|
||||||
|
</a-list-item>
|
||||||
|
<a-list-item v-else class="version-list-item">
|
||||||
|
<span>{{ t('pages.index.panelUpToDate') }}</span>
|
||||||
|
<a-tag color="green">{{ t('pages.index.panelUpToDate') }}</a-tag>
|
||||||
|
</a-list-item>
|
||||||
|
</a-list>
|
||||||
|
|
||||||
|
<div class="actions-row">
|
||||||
|
<a-button type="primary" :disabled="!info.updateAvailable" @click="updatePanel">
|
||||||
|
<template #icon>
|
||||||
|
<CloudDownloadOutlined />
|
||||||
|
</template>
|
||||||
|
{{ t('pages.index.updatePanel') }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mb-12 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { AreaChartOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import { CPUFormatter, SizeFormatter } from '@/utils';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
status: { type: Object, required: true },
|
||||||
|
isMobile: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// AD-Vue's default 120px dashboard renders the percent text at ~36px
|
||||||
|
// which dwarfs the rest of the card. 70 (60 on mobile) plus the
|
||||||
|
// :deep(.ant-progress-text) override below keep the gauges compact.
|
||||||
|
const gaugeSize = computed(() => (props.isMobile ? 60 : 70));
|
||||||
|
|
||||||
|
// AD-Vue's default unfinished trail (rgba(0,0,0,0.06) /
|
||||||
|
// rgba(255,255,255,0.08)) is invisible against the light card; a
|
||||||
|
// neutral mid-gray reads on both themes.
|
||||||
|
const trailColor = 'rgba(128, 128, 128, 0.25)';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-card hoverable>
|
||||||
|
<a-row :gutter="[0, isMobile ? 16 : 0]">
|
||||||
|
<!-- CPU + Memory -->
|
||||||
|
<a-col :xs="24" :md="12">
|
||||||
|
<a-row>
|
||||||
|
<a-col :span="12" class="text-center">
|
||||||
|
<a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color" :trail-color="trailColor"
|
||||||
|
:percent="status.cpu.percent" :width="gaugeSize" />
|
||||||
|
<div>
|
||||||
|
<b>{{ t('pages.index.cpu') }}:</b> {{ CPUFormatter.cpuCoreFormat(status.cpuCores) }}
|
||||||
|
<a-tooltip>
|
||||||
|
<template #title>
|
||||||
|
<div><b>{{ t('pages.index.logicalProcessors') }}:</b> {{ status.logicalPro }}</div>
|
||||||
|
<div><b>{{ t('pages.index.frequency') }}:</b> {{ CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<AreaChartOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="12" class="text-center">
|
||||||
|
<a-progress type="dashboard" status="normal" :stroke-color="status.mem.color" :trail-color="trailColor"
|
||||||
|
:percent="status.mem.percent" :width="gaugeSize" />
|
||||||
|
<div>
|
||||||
|
<b>{{ t('pages.index.memory') }}:</b> {{ SizeFormatter.sizeFormat(status.mem.current) }} /
|
||||||
|
{{ SizeFormatter.sizeFormat(status.mem.total) }}
|
||||||
|
</div>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- Swap + Disk -->
|
||||||
|
<a-col :xs="24" :md="12">
|
||||||
|
<a-row>
|
||||||
|
<a-col :span="12" class="text-center">
|
||||||
|
<a-progress type="dashboard" status="normal" :stroke-color="status.swap.color" :trail-color="trailColor"
|
||||||
|
:percent="status.swap.percent" :width="gaugeSize" />
|
||||||
|
<div>
|
||||||
|
<b>{{ t('pages.index.swap') }}:</b> {{ SizeFormatter.sizeFormat(status.swap.current) }} /
|
||||||
|
{{ SizeFormatter.sizeFormat(status.swap.total) }}
|
||||||
|
</div>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="12" class="text-center">
|
||||||
|
<a-progress type="dashboard" status="normal" :stroke-color="status.disk.color" :trail-color="trailColor"
|
||||||
|
:percent="status.disk.percent" :width="gaugeSize" />
|
||||||
|
<div>
|
||||||
|
<b>{{ t('pages.index.storage') }}:</b> {{ SizeFormatter.sizeFormat(status.disk.current) }} /
|
||||||
|
{{ SizeFormatter.sizeFormat(status.disk.total) }}
|
||||||
|
</div>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pin the percent number to a label-sized 14px — AD-Vue scales it
|
||||||
|
* from the SVG's intrinsic size, so :width alone leaves it too big. */
|
||||||
|
:deep(.ant-progress-text) {
|
||||||
|
font-size: 14px !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { HttpUtil, SizeFormatter } from '@/utils';
|
||||||
|
import Sparkline from '@/components/Sparkline.vue';
|
||||||
|
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
const modalWidth = computed(() => (isMobile.value ? '95vw' : '900px'));
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
status: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open']);
|
||||||
|
|
||||||
|
// One tab per system metric. The order here drives the tab order in
|
||||||
|
// the UI; everything else (axis label, tooltip unit, fetch URL) is
|
||||||
|
// looked up from the active key. Adding another metric is one row.
|
||||||
|
const metrics = [
|
||||||
|
{ key: 'cpu', tab: 'CPU', valueMax: 100, unit: '%', stroke: '' },
|
||||||
|
{ key: 'mem', tab: 'RAM', valueMax: 100, unit: '%', stroke: '#7c4dff' },
|
||||||
|
{ key: 'netUp', tab: 'Net Up', valueMax: null, unit: 'B/s', stroke: '#1890ff' },
|
||||||
|
{ key: 'netDown', tab: 'Net Down', valueMax: null, unit: 'B/s', stroke: '#13c2c2' },
|
||||||
|
{ key: 'online', tab: 'Online', valueMax: null, unit: '', stroke: '#52c41a' },
|
||||||
|
{ key: 'load1', tab: 'Load 1m', valueMax: null, unit: '', stroke: '#fa8c16' },
|
||||||
|
{ key: 'load5', tab: 'Load 5m', valueMax: null, unit: '', stroke: '#f5222d' },
|
||||||
|
{ key: 'load15', tab: 'Load 15m', valueMax: null, unit: '', stroke: '#a0d911' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeKey = ref('cpu');
|
||||||
|
const bucket = ref(2);
|
||||||
|
const points = ref([]);
|
||||||
|
const labels = ref([]);
|
||||||
|
|
||||||
|
const activeMetric = computed(() => metrics.find((m) => m.key === activeKey.value));
|
||||||
|
|
||||||
|
// CPU keeps using the status-card color so the modal visually echoes
|
||||||
|
// the dot in StatusCard. Non-CPU tabs each get their own constant color.
|
||||||
|
const strokeColor = computed(() => {
|
||||||
|
const m = activeMetric.value;
|
||||||
|
if (m?.stroke) return m.stroke;
|
||||||
|
return props.status?.cpu?.color || '#008771';
|
||||||
|
});
|
||||||
|
|
||||||
|
function unitFormatter(unit) {
|
||||||
|
if (unit === 'B/s') {
|
||||||
|
return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0))}/s`;
|
||||||
|
}
|
||||||
|
if (unit === '%') {
|
||||||
|
return (v) => `${Number(v).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
// Plain numbers: load averages get two decimals, online client count
|
||||||
|
// is integer. Heuristic on the unit-less metric key is good enough.
|
||||||
|
return (v) => {
|
||||||
|
const n = Number(v) || 0;
|
||||||
|
if (activeKey.value === 'online') return String(Math.round(n));
|
||||||
|
return n.toFixed(2);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const yFormatter = computed(() => unitFormatter(activeMetric.value?.unit ?? ''));
|
||||||
|
|
||||||
|
async function fetchBucket() {
|
||||||
|
const m = activeMetric.value;
|
||||||
|
if (!m) return;
|
||||||
|
try {
|
||||||
|
const url = `/panel/api/server/history/${m.key}/${bucket.value}`;
|
||||||
|
const msg = await HttpUtil.get(url);
|
||||||
|
if (msg?.success && Array.isArray(msg.obj)) {
|
||||||
|
const vals = [];
|
||||||
|
const labs = [];
|
||||||
|
for (const p of msg.obj) {
|
||||||
|
const d = new Date(p.t * 1000);
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
labs.push(bucket.value >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
|
||||||
|
vals.push(Number(p.v) || 0);
|
||||||
|
}
|
||||||
|
labels.value = labs;
|
||||||
|
points.value = vals;
|
||||||
|
} else {
|
||||||
|
labels.value = [];
|
||||||
|
points.value = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch history bucket', e);
|
||||||
|
labels.value = [];
|
||||||
|
points.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (next) {
|
||||||
|
activeKey.value = 'cpu';
|
||||||
|
fetchBucket();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
watch([activeKey, bucket], () => {
|
||||||
|
if (props.open) fetchBucket();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" @cancel="close">
|
||||||
|
<template #title>
|
||||||
|
{{ t('pages.index.systemHistoryTitle') }}
|
||||||
|
<a-select v-model:value="bucket" size="small" class="bucket-select">
|
||||||
|
<a-select-option :value="2">2m</a-select-option>
|
||||||
|
<a-select-option :value="30">30m</a-select-option>
|
||||||
|
<a-select-option :value="60">1h</a-select-option>
|
||||||
|
<a-select-option :value="120">2h</a-select-option>
|
||||||
|
<a-select-option :value="180">3h</a-select-option>
|
||||||
|
<a-select-option :value="300">5h</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-tabs v-model:active-key="activeKey" size="small" class="history-tabs">
|
||||||
|
<a-tab-pane v-for="m in metrics" :key="m.key" :tab="m.tab" />
|
||||||
|
</a-tabs>
|
||||||
|
|
||||||
|
<div class="cpu-chart-wrap">
|
||||||
|
<div class="cpu-chart-meta">
|
||||||
|
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
|
||||||
|
</div>
|
||||||
|
<Sparkline :data="points" :labels="labels" :vb-width="840" :height="220" :stroke="strokeColor" :stroke-width="2.2"
|
||||||
|
:show-grid="true" :show-axes="true" :tick-count-x="5" :max-points="points.length || 1" :fill-opacity="0.18"
|
||||||
|
:marker-radius="3.2" :show-tooltip="true" :value-min="0" :value-max="activeMetric?.valueMax ?? null"
|
||||||
|
:y-formatter="yFormatter" />
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bucket-select {
|
||||||
|
width: 80px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tabs {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpu-chart-wrap {
|
||||||
|
padding: 8px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpu-chart-meta {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Modal } from 'ant-design-vue';
|
||||||
|
import { ReloadOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import CustomGeoSection from './CustomGeoSection.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
status: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'busy']);
|
||||||
|
|
||||||
|
const activeKey = ref('1');
|
||||||
|
const versions = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
// Geofiles list is hardcoded in the legacy panel — same set of files
|
||||||
|
// served from /panel/api/server/updateGeofile/{name}.
|
||||||
|
const GEOFILES = ['geosite.dat', 'geoip.dat', 'geosite_IR.dat', 'geoip_IR.dat', 'geosite_RU.dat', 'geoip_RU.dat'];
|
||||||
|
|
||||||
|
async function fetchVersions() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
|
||||||
|
if (msg?.success) versions.value = msg.obj || [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchXrayVersion(version) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('pages.index.xraySwitchVersionDialog'),
|
||||||
|
content: t('pages.index.xraySwitchVersionDialogDesc').replace('#version#', version),
|
||||||
|
okText: t('confirm'),
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: async () => {
|
||||||
|
close();
|
||||||
|
emit('busy', { busy: true, tip: t('pages.index.dontRefresh') });
|
||||||
|
try {
|
||||||
|
await HttpUtil.post(`/panel/api/server/installXray/${version}`);
|
||||||
|
} finally {
|
||||||
|
emit('busy', { busy: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGeofile(fileName) {
|
||||||
|
const isSingle = !!fileName;
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('pages.index.geofileUpdateDialog'),
|
||||||
|
content: isSingle
|
||||||
|
? t('pages.index.geofileUpdateDialogDesc').replace('#filename#', fileName)
|
||||||
|
: t('pages.index.geofilesUpdateDialogDesc'),
|
||||||
|
okText: t('confirm'),
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: async () => {
|
||||||
|
close();
|
||||||
|
emit('busy', { busy: true, tip: t('pages.index.dontRefresh') });
|
||||||
|
const url = isSingle
|
||||||
|
? `/panel/api/server/updateGeofile/${fileName}`
|
||||||
|
: '/panel/api/server/updateGeofile';
|
||||||
|
try {
|
||||||
|
await HttpUtil.post(url);
|
||||||
|
} finally {
|
||||||
|
emit('busy', { busy: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => { if (next) fetchVersions(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="t('pages.index.xrayUpdates')" :closable="true" :footer="null" @cancel="close">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<a-collapse v-model:active-key="activeKey" accordion>
|
||||||
|
<a-collapse-panel key="1" header="Xray">
|
||||||
|
<a-alert type="warning" class="mb-12" :message="t('pages.index.xraySwitchClickDesk')" show-icon />
|
||||||
|
<a-list bordered class="version-list">
|
||||||
|
<a-list-item v-for="(version, index) in versions" :key="version" class="version-list-item">
|
||||||
|
<a-tag :color="index % 2 === 0 ? 'purple' : 'green'">{{ version }}</a-tag>
|
||||||
|
<a-radio :checked="version === `v${status?.xray?.version}`" @click="switchXrayVersion(version)" />
|
||||||
|
</a-list-item>
|
||||||
|
</a-list>
|
||||||
|
</a-collapse-panel>
|
||||||
|
|
||||||
|
<a-collapse-panel key="2" header="Geofiles">
|
||||||
|
<a-list bordered class="version-list">
|
||||||
|
<a-list-item v-for="(file, index) in GEOFILES" :key="file" class="version-list-item">
|
||||||
|
<a-tag :color="index % 2 === 0 ? 'purple' : 'green'">{{ file }}</a-tag>
|
||||||
|
<a-tooltip :title="t('update')">
|
||||||
|
<ReloadOutlined class="reload-icon" @click="updateGeofile(file)" />
|
||||||
|
</a-tooltip>
|
||||||
|
</a-list-item>
|
||||||
|
</a-list>
|
||||||
|
<div class="actions-row">
|
||||||
|
<a-button @click="updateGeofile('')">{{ t('pages.index.geofilesUpdateAll') }}</a-button>
|
||||||
|
</div>
|
||||||
|
</a-collapse-panel>
|
||||||
|
|
||||||
|
<a-collapse-panel key="3" :header="t('pages.index.customGeoTitle')">
|
||||||
|
<CustomGeoSection :active="activeKey === '3'" />
|
||||||
|
</a-collapse-panel>
|
||||||
|
</a-collapse>
|
||||||
|
</a-spin>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mb-12 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reload-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
|
||||||
|
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||||
|
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { datepicker } = useDatepicker();
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open']);
|
||||||
|
|
||||||
|
const rows = ref('20');
|
||||||
|
const filter = ref('');
|
||||||
|
const showDirect = ref(true);
|
||||||
|
const showBlocked = ref(true);
|
||||||
|
const showProxy = ref(true);
|
||||||
|
const loading = ref(false);
|
||||||
|
const logs = ref([]);
|
||||||
|
|
||||||
|
// Newest first.
|
||||||
|
const orderedLogs = computed(() => [...logs.value].reverse());
|
||||||
|
|
||||||
|
const EVENT_LABELS = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
|
||||||
|
const EVENT_COLORS = { 0: 'green', 1: 'red', 2: 'blue' };
|
||||||
|
|
||||||
|
function eventLabel(ev) { return EVENT_LABELS[ev] || String(ev ?? ''); }
|
||||||
|
function eventColor(ev) { return EVENT_COLORS[ev] || 'default'; }
|
||||||
|
|
||||||
|
function fullDate(value) {
|
||||||
|
return IntlUtil.formatDate(value, datepicker.value);
|
||||||
|
}
|
||||||
|
function shortTime(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
const d = new Date(value);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/server/xraylogs/${rows.value}`, {
|
||||||
|
filter: filter.value,
|
||||||
|
showDirect: showDirect.value,
|
||||||
|
showBlocked: showBlocked.value,
|
||||||
|
showProxy: showProxy.value,
|
||||||
|
});
|
||||||
|
if (msg?.success) logs.value = msg.obj || [];
|
||||||
|
await PromiseUtil.sleep(300);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
if (!Array.isArray(logs.value) || logs.value.length === 0) {
|
||||||
|
FileManager.downloadTextFile('', 'x-ui.log');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = logs.value.map((l) => {
|
||||||
|
try {
|
||||||
|
const dt = l.DateTime ? new Date(l.DateTime) : null;
|
||||||
|
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
|
||||||
|
const eventText = eventLabel(l.Event);
|
||||||
|
const emailPart = l.Email ? ` Email=${l.Email}` : '';
|
||||||
|
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
|
||||||
|
} catch (_e) {
|
||||||
|
return JSON.stringify(l);
|
||||||
|
}
|
||||||
|
}).join('\n');
|
||||||
|
FileManager.downloadTextFile(lines, 'x-ui.log');
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => { if (next) refresh(); });
|
||||||
|
watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refresh(); });
|
||||||
|
|
||||||
|
const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth"
|
||||||
|
:class="{ 'xraylog-modal-mobile': isMobile }" @cancel="close">
|
||||||
|
<template #title>
|
||||||
|
{{ t('pages.index.logs') }}
|
||||||
|
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-form layout="inline" class="log-toolbar">
|
||||||
|
<a-form-item>
|
||||||
|
<a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
|
||||||
|
<a-select-option value="10">10</a-select-option>
|
||||||
|
<a-select-option value="20">20</a-select-option>
|
||||||
|
<a-select-option value="50">50</a-select-option>
|
||||||
|
<a-select-option value="100">100</a-select-option>
|
||||||
|
<a-select-option value="500">500</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label="t('filter')" class="filter-item">
|
||||||
|
<a-input v-model:value="filter" size="small" @keyup.enter="refresh" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-checkbox v-model:checked="showDirect">Direct</a-checkbox>
|
||||||
|
<a-checkbox v-model:checked="showBlocked">Blocked</a-checkbox>
|
||||||
|
<a-checkbox v-model:checked="showProxy">Proxy</a-checkbox>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item class="download-item">
|
||||||
|
<a-button type="primary" @click="download">
|
||||||
|
<template #icon>
|
||||||
|
<DownloadOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<div class="log-container" :class="{ 'log-container-mobile': isMobile }">
|
||||||
|
<div v-if="orderedLogs.length === 0" class="log-empty">No Record...</div>
|
||||||
|
|
||||||
|
<template v-else-if="isMobile">
|
||||||
|
<div v-for="(log, idx) in orderedLogs" :key="idx" class="log-card">
|
||||||
|
<div class="log-card-head">
|
||||||
|
<span class="log-time" :title="fullDate(log.DateTime)">{{ shortTime(log.DateTime) }}</span>
|
||||||
|
<a-tag :color="eventColor(log.Event)" class="log-event-tag">{{ eventLabel(log.Event) }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="log-route">
|
||||||
|
<span class="log-addr">{{ log.FromAddress }}</span>
|
||||||
|
<span class="log-arrow">→</span>
|
||||||
|
<span class="log-addr">{{ log.ToAddress }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-meta">
|
||||||
|
<span v-if="log.Inbound" class="log-meta-pair">
|
||||||
|
<span class="log-meta-key">in</span>
|
||||||
|
<span class="log-meta-val">{{ log.Inbound }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="log.Outbound" class="log-meta-pair">
|
||||||
|
<span class="log-meta-key">out</span>
|
||||||
|
<span class="log-meta-val">{{ log.Outbound }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="log.Email" class="log-meta-pair">
|
||||||
|
<span class="log-meta-key">email</span>
|
||||||
|
<span class="log-meta-val">{{ log.Email }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<table v-else class="xraylog-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>From</th>
|
||||||
|
<th>To</th>
|
||||||
|
<th>Inbound</th>
|
||||||
|
<th>Outbound</th>
|
||||||
|
<th>Email</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(log, idx) in orderedLogs" :key="idx" :class="`log-row-${log.Event}`">
|
||||||
|
<td><b>{{ fullDate(log.DateTime) }}</b></td>
|
||||||
|
<td>{{ log.FromAddress }}</td>
|
||||||
|
<td>{{ log.ToAddress }}</td>
|
||||||
|
<td>{{ log.Inbound }}</td>
|
||||||
|
<td>{{ log.Outbound }}</td>
|
||||||
|
<td>{{ log.Email }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reload-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toolbar .filter-item {
|
||||||
|
flex: 1 1 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toolbar .download-item {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
/* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
|
||||||
|
below so blocked/proxy rows keep ≥4.5:1 contrast on darker surfaces. */
|
||||||
|
--log-blocked: #e04141;
|
||||||
|
--log-proxy: #3c89e8;
|
||||||
|
--log-divider: rgba(128, 128, 128, 0.18);
|
||||||
|
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container-mobile {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-empty {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.5;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-card {
|
||||||
|
border-bottom: 1px solid var(--log-divider);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-card:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event-tag {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-route {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-addr {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-arrow {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-meta-pair {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-meta-key {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.6;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .log-container {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
|
||||||
|
--log-blocked: #ff7575;
|
||||||
|
--log-proxy: #6aa6ee;
|
||||||
|
--log-divider: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="ultra-dark"]) .log-container {
|
||||||
|
--log-blocked: #ff8a8a;
|
||||||
|
--log-proxy: #7fb6f1;
|
||||||
|
--log-divider: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: pull the modal flush with the screen edges. */
|
||||||
|
:global(.xraylog-modal-mobile) {
|
||||||
|
top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.xraylog-modal-mobile .ant-modal-content) {
|
||||||
|
border-radius: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.xraylog-modal-mobile .ant-modal-body) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xraylog-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xraylog-table td,
|
||||||
|
.xraylog-table th {
|
||||||
|
padding: 2px 15px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xraylog-table .log-row-1 {
|
||||||
|
color: var(--log-blocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xraylog-table .log-row-2 {
|
||||||
|
color: var(--log-proxy);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import {
|
||||||
|
BarsOutlined,
|
||||||
|
PoweroffOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
ToolOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
status: { type: Object, required: true },
|
||||||
|
isMobile: { type: Boolean, default: false },
|
||||||
|
ipLimitEnable: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['stop-xray', 'restart-xray', 'open-logs', 'open-xray-logs', 'open-version-switch']);
|
||||||
|
|
||||||
|
const XRAY_STATE_KEYS = {
|
||||||
|
running: 'pages.index.xrayStatusRunning',
|
||||||
|
stop: 'pages.index.xrayStatusStop',
|
||||||
|
error: 'pages.index.xrayStatusError',
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateText = computed(() =>
|
||||||
|
t(XRAY_STATE_KEYS[props.status.xray.state] ?? 'pages.index.xrayStatusUnknown'),
|
||||||
|
);
|
||||||
|
|
||||||
|
function badgeAnimationClass(color) {
|
||||||
|
if (color === 'green') return 'xray-running-animation';
|
||||||
|
if (color === 'orange') return 'xray-stop-animation';
|
||||||
|
if (color === 'red') return 'xray-error-animation';
|
||||||
|
return 'xray-processing-animation';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-card hoverable>
|
||||||
|
<template #title>
|
||||||
|
<a-space direction="horizontal">
|
||||||
|
<span>{{ t('pages.index.xrayStatus') }}</span>
|
||||||
|
<a-tag v-if="isMobile && status.xray.version && status.xray.version !== 'Unknown'" color="green">
|
||||||
|
v{{ status.xray.version }}
|
||||||
|
</a-tag>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #extra>
|
||||||
|
<template v-if="status.xray.state !== 'error'">
|
||||||
|
<a-badge status="processing" :class="['xray-processing-animation', badgeAnimationClass(status.xray.color)]"
|
||||||
|
:text="stateText" :color="status.xray.color" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a-popover>
|
||||||
|
<template #title>
|
||||||
|
<a-row type="flex" align="middle" justify="space-between">
|
||||||
|
<a-col><span>{{ t('pages.index.xrayStatusError') }}</span></a-col>
|
||||||
|
<a-col>
|
||||||
|
<BarsOutlined class="cursor-pointer" @click="$emit('open-logs')" />
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<span v-for="(line, i) in (status.xray.errorMsg || '').split('\n')" :key="i" class="error-line">
|
||||||
|
{{ line }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<a-badge status="processing" :text="stateText" :color="status.xray.color"
|
||||||
|
:class="['xray-processing-animation', 'xray-error-animation']" />
|
||||||
|
</a-popover>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<a-space v-if="ipLimitEnable" direction="horizontal" class="action" @click="$emit('open-xray-logs')">
|
||||||
|
<BarsOutlined />
|
||||||
|
<span v-if="!isMobile">{{ t('pages.index.logs') }}</span>
|
||||||
|
</a-space>
|
||||||
|
<a-space direction="horizontal" class="action" @click="$emit('stop-xray')">
|
||||||
|
<PoweroffOutlined />
|
||||||
|
<span v-if="!isMobile">{{ t('pages.index.stopXray') }}</span>
|
||||||
|
</a-space>
|
||||||
|
<a-space direction="horizontal" class="action" @click="$emit('restart-xray')">
|
||||||
|
<ReloadOutlined />
|
||||||
|
<span v-if="!isMobile">{{ t('pages.index.restartXray') }}</span>
|
||||||
|
</a-space>
|
||||||
|
<a-space direction="horizontal" class="action" @click="$emit('open-version-switch')">
|
||||||
|
<ToolOutlined />
|
||||||
|
<span v-if="!isMobile">
|
||||||
|
{{ status.xray.version && status.xray.version !== 'Unknown'
|
||||||
|
? `v${status.xray.version}`
|
||||||
|
: t('pages.index.xraySwitch') }}
|
||||||
|
</span>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.action {
|
||||||
|
cursor: pointer;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-line {
|
||||||
|
display: block;
|
||||||
|
max-width: 400px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Legacy xray-*-animation classes — they need to be global so they
|
||||||
|
* pierce the AD-Vue badge's internal DOM (.ant-badge-status-*). */
|
||||||
|
.xray-processing-animation .ant-badge-status-dot {
|
||||||
|
animation: xray-pulse 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-running-animation .ant-badge-status-processing::after {
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-stop-animation .ant-badge-status-processing::after {
|
||||||
|
border-color: #fa8c16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-error-animation .ant-badge-status-processing::after {
|
||||||
|
border-color: #f5222d;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes xray-pulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
10% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { UserOutlined, LockOutlined, KeyOutlined, SettingOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import { HttpUtil, LanguageManager } from '@/utils';
|
||||||
|
import {
|
||||||
|
antdThemeConfig,
|
||||||
|
currentTheme,
|
||||||
|
theme as themeState,
|
||||||
|
toggleTheme,
|
||||||
|
toggleUltra,
|
||||||
|
pauseAnimationsUntilLeave,
|
||||||
|
} from '@/composables/useTheme.js';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const fetched = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const twoFactorEnable = ref(false);
|
||||||
|
|
||||||
|
const user = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
twoFactorCode: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
|
|
||||||
|
const headlineWords = computed(() => [t('pages.login.hello'), t('pages.login.title')]);
|
||||||
|
const HEADLINE_INTERVAL_MS = 2000;
|
||||||
|
const headlineIndex = ref(0);
|
||||||
|
let headlineTimer = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
headlineTimer = window.setInterval(() => {
|
||||||
|
headlineIndex.value = (headlineIndex.value + 1) % headlineWords.value.length;
|
||||||
|
}, HEADLINE_INTERVAL_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (headlineTimer != null) window.clearInterval(headlineTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const msg = await HttpUtil.post('/getTwoFactorEnable');
|
||||||
|
if (msg.success) twoFactorEnable.value = !!msg.obj;
|
||||||
|
fetched.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post('/login', user);
|
||||||
|
if (msg.success) window.location.href = basePath + 'panel/';
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = ref(LanguageManager.getLanguage());
|
||||||
|
function onLangChange(next) {
|
||||||
|
LanguageManager.setLanguage(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same Light -> Dark -> Ultra Dark -> Light cycle the sidebar's brand
|
||||||
|
* button uses, so the login chrome offers a one-click theme toggle
|
||||||
|
* without the popover ceremony. */
|
||||||
|
function cycleTheme() {
|
||||||
|
pauseAnimationsUntilLeave('login-theme-cycle');
|
||||||
|
if (!themeState.isDark) {
|
||||||
|
toggleTheme();
|
||||||
|
if (themeState.isUltra) toggleUltra();
|
||||||
|
} else if (!themeState.isUltra) {
|
||||||
|
toggleUltra();
|
||||||
|
} else {
|
||||||
|
toggleUltra();
|
||||||
|
toggleTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-config-provider :theme="antdThemeConfig">
|
||||||
|
<a-layout class="login-app" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||||
|
<a-layout-content class="login-content">
|
||||||
|
<!-- Floating chrome at top-right: theme cycle (Light/Dark/Ultra)
|
||||||
|
plus a language picker hidden behind the gear popover. -->
|
||||||
|
<div class="login-toolbar">
|
||||||
|
<button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
|
||||||
|
@click="cycleTheme">
|
||||||
|
<svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="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>
|
||||||
|
<svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="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 v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
|
||||||
|
stroke-linecap="round" stroke-linejoin="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>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a-popover :overlay-class-name="currentTheme" :title="t('pages.settings.language')" placement="bottomRight"
|
||||||
|
trigger="click">
|
||||||
|
<template #content>
|
||||||
|
<a-space direction="vertical" :size="10" class="settings-popover">
|
||||||
|
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
|
||||||
|
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value">
|
||||||
|
<span :aria-label="l.name">{{ l.icon }}</span>
|
||||||
|
<span>{{ l.name }}</span>
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
<a-button shape="circle" class="toolbar-btn" :aria-label="t('menu.settings')">
|
||||||
|
<template #icon>
|
||||||
|
<SettingOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-wrapper">
|
||||||
|
<div v-if="!fetched" class="login-loading">
|
||||||
|
<a-spin size="large" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="login-card">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-name">3X-UI</span>
|
||||||
|
<span class="brand-accent" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
<h2 class="welcome">
|
||||||
|
<Transition name="headline" mode="out-in">
|
||||||
|
<b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
|
||||||
|
</Transition>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<a-form layout="vertical" class="login-form" @submit.prevent="login">
|
||||||
|
<a-form-item :label="t('username')">
|
||||||
|
<a-input v-model:value="user.username" autocomplete="username" name="username" size="large"
|
||||||
|
:placeholder="t('username')" autofocus required>
|
||||||
|
<template #prefix>
|
||||||
|
<UserOutlined />
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item :label="t('password')">
|
||||||
|
<a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
|
||||||
|
size="large" :placeholder="t('password')" required>
|
||||||
|
<template #prefix>
|
||||||
|
<LockOutlined />
|
||||||
|
</template>
|
||||||
|
</a-input-password>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="twoFactorEnable" :label="t('twoFactorCode')">
|
||||||
|
<a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
|
||||||
|
size="large" :placeholder="t('twoFactorCode')" required>
|
||||||
|
<template #prefix>
|
||||||
|
<KeyOutlined />
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item class="submit-row">
|
||||||
|
<a-button type="primary" html-type="submit" :loading="submitting" size="large" block>
|
||||||
|
{{ submitting ? '' : t('login') }}
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
</a-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-app {
|
||||||
|
--bg-page: #f5f7fa;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--color-text: rgba(0, 0, 0, 0.88);
|
||||||
|
--color-text-subtle: rgba(0, 0, 0, 0.55);
|
||||||
|
--color-accent: #1677ff;
|
||||||
|
--color-border: rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||||
|
--blob-1: rgba(99, 102, 241, 0.45);
|
||||||
|
--blob-2: rgba(236, 72, 153, 0.38);
|
||||||
|
--blob-3: rgba(20, 184, 166, 0.32);
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-app.is-dark {
|
||||||
|
--bg-page: #1e1e1e;
|
||||||
|
--bg-card: #252526;
|
||||||
|
--color-text: rgba(255, 255, 255, 0.92);
|
||||||
|
--color-text-subtle: rgba(255, 255, 255, 0.55);
|
||||||
|
--color-accent: #4096ff;
|
||||||
|
--color-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
--blob-1: rgba(64, 150, 255, 0.40);
|
||||||
|
--blob-2: rgba(168, 85, 247, 0.34);
|
||||||
|
--blob-3: rgba(34, 211, 238, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-app.is-dark.is-ultra {
|
||||||
|
--bg-page: #000;
|
||||||
|
--bg-card: #141414;
|
||||||
|
--color-border: rgba(255, 255, 255, 0.06);
|
||||||
|
--blob-1: rgba(64, 150, 255, 0.22);
|
||||||
|
--blob-2: rgba(168, 85, 247, 0.18);
|
||||||
|
--blob-3: rgba(34, 211, 238, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Three blurred blobs slowly drift across the page; ::before and
|
||||||
|
* ::after carry two of them, the third lives on .login-content::before
|
||||||
|
* so we can animate it independently. */
|
||||||
|
.login-app::before,
|
||||||
|
.login-app::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 70vw;
|
||||||
|
height: 70vw;
|
||||||
|
max-width: 900px;
|
||||||
|
max-height: 900px;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(90px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-app::before {
|
||||||
|
top: -25vw;
|
||||||
|
left: -20vw;
|
||||||
|
background: radial-gradient(circle, var(--blob-1) 0%, transparent 65%);
|
||||||
|
animation: blob-drift-a 24s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-app::after {
|
||||||
|
bottom: -25vw;
|
||||||
|
right: -20vw;
|
||||||
|
background: radial-gradient(circle, var(--blob-2) 0%, transparent 65%);
|
||||||
|
animation: blob-drift-b 30s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-content::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 30%;
|
||||||
|
left: 50%;
|
||||||
|
width: 50vw;
|
||||||
|
height: 50vw;
|
||||||
|
max-width: 700px;
|
||||||
|
max-height: 700px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, var(--blob-3) 0%, transparent 65%);
|
||||||
|
filter: blur(90px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
will-change: transform;
|
||||||
|
animation: blob-drift-c 36s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blob-drift-a {
|
||||||
|
0% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translate(18vw, 10vh) scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translate(34vw, 22vh) scale(1.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blob-drift-b {
|
||||||
|
0% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translate(-16vw, -10vh) scale(1.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translate(-30vw, -22vh) scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blob-drift-c {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translate(-20%, -20%) scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translate(-80%, -10%) scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|
||||||
|
.login-app::before,
|
||||||
|
.login-app::after,
|
||||||
|
.login-content::before {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-app :deep(.ant-layout-content) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-content {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-content>* {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-toolbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 10;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-cycle {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-cycle:hover,
|
||||||
|
.theme-cycle:focus-visible {
|
||||||
|
background-color: rgba(64, 150, 255, 0.1);
|
||||||
|
color: #4096ff;
|
||||||
|
transform: scale(1.05);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-cycle svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-loading {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px 32px 28px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-card {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-accent {
|
||||||
|
display: block;
|
||||||
|
width: 40px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
min-height: 42px;
|
||||||
|
margin: 12px 0 28px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome b {
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline-enter-active,
|
||||||
|
.headline-leave-active {
|
||||||
|
transition: opacity 280ms ease, transform 280ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form :deep(.ant-form-item-label > label) {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-row {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-popover {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
mode: { type: String, default: 'add' }, // 'add' | 'edit'
|
||||||
|
node: { type: Object, default: null },
|
||||||
|
testConnection: { type: Function, required: true },
|
||||||
|
save: { type: Function, required: true }, // (payload) => Promise<msg>
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// Default form shape — used for "add" mode and to reset between
|
||||||
|
// edits. Sane defaults: HTTPS, port 2053, base path '/', enabled.
|
||||||
|
function defaultForm() {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
remark: '',
|
||||||
|
scheme: 'https',
|
||||||
|
address: '',
|
||||||
|
port: 2053,
|
||||||
|
basePath: '/',
|
||||||
|
apiToken: '',
|
||||||
|
enable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive(defaultForm());
|
||||||
|
const submitting = ref(false);
|
||||||
|
const testing = ref(false);
|
||||||
|
const testResult = ref(null); // { status, latencyMs, xrayVersion, error }
|
||||||
|
// Reset the form whenever the modal is opened. In edit mode we copy
|
||||||
|
// the existing node into the form fields; in add mode we wipe back
|
||||||
|
// to defaults so a previous edit doesn't leak through.
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
if (!open) return;
|
||||||
|
Object.assign(form, defaultForm());
|
||||||
|
testResult.value = null;
|
||||||
|
if (props.mode === 'edit' && props.node) {
|
||||||
|
Object.assign(form, props.node);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = computed(() =>
|
||||||
|
props.mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode'),
|
||||||
|
);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!submitting.value) emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload() {
|
||||||
|
return {
|
||||||
|
id: form.id || 0,
|
||||||
|
name: form.name?.trim() || '',
|
||||||
|
remark: form.remark?.trim() || '',
|
||||||
|
scheme: form.scheme || 'https',
|
||||||
|
address: form.address?.trim() || '',
|
||||||
|
port: Number(form.port) || 0,
|
||||||
|
basePath: form.basePath?.trim() || '/',
|
||||||
|
apiToken: form.apiToken?.trim() || '',
|
||||||
|
enable: !!form.enable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTest() {
|
||||||
|
testing.value = true;
|
||||||
|
testResult.value = null;
|
||||||
|
try {
|
||||||
|
const payload = buildPayload();
|
||||||
|
if (!payload.address || !payload.port) {
|
||||||
|
message.error(t('pages.nodes.toasts.fillRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = await props.testConnection(payload);
|
||||||
|
if (msg?.success) {
|
||||||
|
testResult.value = msg.obj;
|
||||||
|
} else {
|
||||||
|
testResult.value = { status: 'offline', error: msg?.msg || 'unknown error' };
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
testing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
const payload = buildPayload();
|
||||||
|
if (!payload.name || !payload.address || !payload.port) {
|
||||||
|
message.error(t('pages.nodes.toasts.fillRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await props.save(payload);
|
||||||
|
if (msg?.success) {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="title" :confirm-loading="submitting" :ok-text="t('save')" :cancel-text="t('cancel')"
|
||||||
|
:mask-closable="false" width="640px" @ok="onSave" @cancel="close">
|
||||||
|
<a-form layout="vertical" :model="form">
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.nodes.name')" required>
|
||||||
|
<a-input v-model:value="form.name" :placeholder="t('pages.nodes.namePlaceholder')" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.nodes.remark')">
|
||||||
|
<a-input v-model:value="form.remark" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-form-item :label="t('pages.nodes.scheme')">
|
||||||
|
<a-select v-model:value="form.scheme">
|
||||||
|
<a-select-option value="https">https</a-select-option>
|
||||||
|
<a-select-option value="http">http</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.nodes.address')" required>
|
||||||
|
<a-input v-model:value="form.address" :placeholder="t('pages.nodes.addressPlaceholder')" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-form-item :label="t('pages.nodes.port')" required>
|
||||||
|
<a-input-number v-model:value="form.port" :min="1" :max="65535" style="width: 100%" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.nodes.basePath')">
|
||||||
|
<a-input v-model:value="form.basePath" placeholder="/" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.nodes.enable')">
|
||||||
|
<a-switch v-model:checked="form.enable" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item :label="t('pages.nodes.apiToken')" required>
|
||||||
|
<a-input-password v-model:value="form.apiToken" :placeholder="t('pages.nodes.apiTokenPlaceholder')" />
|
||||||
|
<div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<div class="test-row">
|
||||||
|
<a-button :loading="testing" @click="onTest">
|
||||||
|
{{ t('pages.nodes.testConnection') }}
|
||||||
|
</a-button>
|
||||||
|
<div v-if="testResult" class="test-result">
|
||||||
|
<a-alert v-if="testResult.status === 'online'" type="success" show-icon
|
||||||
|
:message="t('pages.nodes.connectionOk', { ms: testResult.latencyMs })"
|
||||||
|
:description="testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined" />
|
||||||
|
<a-alert v-else type="error" show-icon :message="t('pages.nodes.connectionFailed')"
|
||||||
|
:description="testResult.error" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import Sparkline from '@/components/Sparkline.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
node: { type: Object, required: true },
|
||||||
|
// Bucket size in seconds — matches the SystemHistoryModal selector.
|
||||||
|
bucket: { type: Number, default: 30 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two parallel series so the panel renders CPU and Mem side-by-side
|
||||||
|
// in a single fetch round-trip per refresh.
|
||||||
|
const cpuPoints = ref([]);
|
||||||
|
const cpuLabels = ref([]);
|
||||||
|
const memPoints = ref([]);
|
||||||
|
const memLabels = ref([]);
|
||||||
|
|
||||||
|
const REFRESH_MS = 15000;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function bucketLabel(unixSec) {
|
||||||
|
const d = new Date(unixSec * 1000);
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
if (props.bucket >= 60) return `${hh}:${mm}`;
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSeries(metric) {
|
||||||
|
try {
|
||||||
|
const url = `/panel/api/nodes/history/${props.node.id}/${metric}/${props.bucket}`;
|
||||||
|
const msg = await HttpUtil.get(url);
|
||||||
|
if (msg?.success && Array.isArray(msg.obj)) {
|
||||||
|
const vals = [];
|
||||||
|
const labs = [];
|
||||||
|
for (const p of msg.obj) {
|
||||||
|
labs.push(bucketLabel(p.t));
|
||||||
|
vals.push(Math.max(0, Math.min(100, Number(p.v) || 0)));
|
||||||
|
}
|
||||||
|
return { vals, labs };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('node history fetch failed', metric, e);
|
||||||
|
}
|
||||||
|
return { vals: [], labs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const [cpu, mem] = await Promise.all([fetchSeries('cpu'), fetchSeries('mem')]);
|
||||||
|
cpuPoints.value = cpu.vals;
|
||||||
|
cpuLabels.value = cpu.labs;
|
||||||
|
memPoints.value = mem.vals;
|
||||||
|
memLabels.value = mem.labs;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refresh();
|
||||||
|
timer = window.setInterval(refresh, REFRESH_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer != null) window.clearInterval(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the parent table re-emits a node row with a different id (rare —
|
||||||
|
// happens when the list is sorted or filtered while the panel is open),
|
||||||
|
// reset and re-fetch.
|
||||||
|
watch(() => props.node?.id, (a, b) => {
|
||||||
|
if (a !== b) refresh();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="node-history-panel">
|
||||||
|
<div class="series">
|
||||||
|
<div class="series-title">{{ t('pages.nodes.cpu') }}</div>
|
||||||
|
<Sparkline :data="cpuPoints" :labels="cpuLabels" :vb-width="640" :height="120" stroke="#008771" :show-grid="true"
|
||||||
|
:show-axes="true" :tick-count-x="4" :max-points="cpuPoints.length || 1" :fill-opacity="0.18"
|
||||||
|
:marker-radius="2.6" :show-tooltip="true" />
|
||||||
|
</div>
|
||||||
|
<div class="series">
|
||||||
|
<div class="series-title">{{ t('pages.nodes.mem') }}</div>
|
||||||
|
<Sparkline :data="memPoints" :labels="memLabels" :vb-width="640" :height="120" stroke="#7c4dff" :show-grid="true"
|
||||||
|
:show-axes="true" :tick-count-x="4" :max-points="memPoints.length || 1" :fill-opacity="0.18"
|
||||||
|
:marker-radius="2.6" :show-tooltip="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.node-history-panel {
|
||||||
|
padding: 8px 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.node-history-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import NodeHistoryPanel from './NodeHistoryPanel.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
nodes: { type: Array, default: () => [] },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
isMobile: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'add',
|
||||||
|
'edit',
|
||||||
|
'delete',
|
||||||
|
'probe',
|
||||||
|
'toggle-enable',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// Render the address column as a clickable URL so admins can jump to
|
||||||
|
// the remote panel directly from the list.
|
||||||
|
const dataSource = computed(() =>
|
||||||
|
props.nodes.map((n) => ({
|
||||||
|
...n,
|
||||||
|
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
|
||||||
|
key: n.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
function statusColor(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'online': return 'green';
|
||||||
|
case 'offline': return 'red';
|
||||||
|
default: return 'default';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative-time formatter — keeps the column compact and avoids
|
||||||
|
// pulling dayjs just for this single use.
|
||||||
|
function relativeTime(unixSeconds) {
|
||||||
|
if (!unixSeconds) return t('pages.nodes.never');
|
||||||
|
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
|
||||||
|
if (diffSec < 5) return t('pages.nodes.justNow');
|
||||||
|
if (diffSec < 60) return `${diffSec}s`;
|
||||||
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
|
||||||
|
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
|
||||||
|
return `${Math.floor(diffSec / 86400)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(secs) {
|
||||||
|
if (!secs) return '-';
|
||||||
|
const days = Math.floor(secs / 86400);
|
||||||
|
const hours = Math.floor((secs % 86400) / 3600);
|
||||||
|
if (days > 0) return `${days}d ${hours}h`;
|
||||||
|
const mins = Math.floor((secs % 3600) / 60);
|
||||||
|
if (hours > 0) return `${hours}h ${mins}m`;
|
||||||
|
return `${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(p) {
|
||||||
|
if (typeof p !== 'number' || isNaN(p)) return '-';
|
||||||
|
return `${p.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-card size="small" hoverable>
|
||||||
|
<div class="toolbar">
|
||||||
|
<a-button type="primary" @click="emit('add')">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
{{ t('pages.nodes.addNode') }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-table :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
|
||||||
|
size="middle" row-key="id">
|
||||||
|
<template #expandedRowRender="{ record }">
|
||||||
|
<NodeHistoryPanel :node="record" />
|
||||||
|
</template>
|
||||||
|
<a-table-column :title="t('pages.nodes.name')" data-index="name" :ellipsis="true">
|
||||||
|
<template #default="{ record }">
|
||||||
|
<div class="name-cell">
|
||||||
|
<span class="name">{{ record.name }}</span>
|
||||||
|
<span v-if="record.remark" class="remark">{{ record.remark }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column :title="t('pages.nodes.address')" data-index="url" :ellipsis="true">
|
||||||
|
<template #default="{ record }">
|
||||||
|
<a :href="record.url" target="_blank" rel="noopener noreferrer">{{ record.url }}</a>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column :title="t('pages.nodes.status')" data-index="status" align="center">
|
||||||
|
<template #default="{ record }">
|
||||||
|
<a-space :size="4">
|
||||||
|
<a-badge
|
||||||
|
:status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
|
||||||
|
<span>{{ t(`pages.nodes.statusValues.${record.status || 'unknown'}`) }}</span>
|
||||||
|
<a-tooltip v-if="record.lastError" :title="record.lastError">
|
||||||
|
<ExclamationCircleOutlined style="color: #faad14" />
|
||||||
|
</a-tooltip>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column :title="t('pages.nodes.cpu')" data-index="cpuPct" align="center" :width="90">
|
||||||
|
<template #default="{ record }">{{ formatPct(record.cpuPct) }}</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column :title="t('pages.nodes.mem')" data-index="memPct" align="center" :width="90">
|
||||||
|
<template #default="{ record }">{{ formatPct(record.memPct) }}</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column :title="t('pages.nodes.xrayVersion')" data-index="xrayVersion" align="center">
|
||||||
|
<template #default="{ record }">
|
||||||
|
{{ record.xrayVersion || '-' }}
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column :title="t('pages.nodes.uptime')" data-index="uptimeSecs" align="center">
|
||||||
|
<template #default="{ record }">{{ formatUptime(record.uptimeSecs) }}</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column :title="t('pages.nodes.latency')" data-index="latencyMs" align="center" :width="100">
|
||||||
|
<template #default="{ record }">
|
||||||
|
<span v-if="record.latencyMs > 0">{{ record.latencyMs }} ms</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column :title="t('pages.nodes.lastHeartbeat')" data-index="lastHeartbeat" align="center" :width="120">
|
||||||
|
<template #default="{ record }">{{ relativeTime(record.lastHeartbeat) }}</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column :title="t('pages.nodes.enable')" data-index="enable" align="center" :width="80">
|
||||||
|
<template #default="{ record }">
|
||||||
|
<a-switch :checked="record.enable" size="small" @change="(v) => emit('toggle-enable', record, v)" />
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column :title="t('pages.nodes.actions')" align="center" :width="160" fixed="right">
|
||||||
|
<template #default="{ record }">
|
||||||
|
<a-space>
|
||||||
|
<a-tooltip :title="t('pages.nodes.probe')">
|
||||||
|
<a-button type="text" size="small" @click="emit('probe', record)">
|
||||||
|
<template #icon>
|
||||||
|
<ThunderboltOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :title="t('edit')">
|
||||||
|
<a-button type="text" size="small" @click="emit('edit', record)">
|
||||||
|
<template #icon>
|
||||||
|
<EditOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :title="t('delete')">
|
||||||
|
<a-button type="text" size="small" danger @click="emit('delete', record)">
|
||||||
|
<template #icon>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remark {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Modal, message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
CloudServerOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||||
|
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||||
|
import AppSidebar from '@/components/AppSidebar.vue';
|
||||||
|
import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||||
|
import NodeList from './NodeList.vue';
|
||||||
|
import NodeFormModal from './NodeFormModal.vue';
|
||||||
|
import { useNodes } from './useNodes.js';
|
||||||
|
import { useWebSocket } from '@/composables/useWebSocket.js';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const {
|
||||||
|
nodes,
|
||||||
|
loading,
|
||||||
|
fetched,
|
||||||
|
totals,
|
||||||
|
applyNodesEvent,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
setEnable,
|
||||||
|
testConnection,
|
||||||
|
probe,
|
||||||
|
} = useNodes();
|
||||||
|
|
||||||
|
// Live updates — NodeHeartbeatJob pushes the fresh list every 10s.
|
||||||
|
useWebSocket({ nodes: applyNodesEvent });
|
||||||
|
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
|
||||||
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
|
const requestUri = window.location.pathname;
|
||||||
|
|
||||||
|
// === Form modal state =================================================
|
||||||
|
const formOpen = ref(false);
|
||||||
|
const formMode = ref('add');
|
||||||
|
const formNode = ref(null);
|
||||||
|
|
||||||
|
function onAdd() {
|
||||||
|
formMode.value = 'add';
|
||||||
|
formNode.value = null;
|
||||||
|
formOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEdit(node) {
|
||||||
|
formMode.value = 'edit';
|
||||||
|
formNode.value = { ...node };
|
||||||
|
formOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save callback the modal hands its payload to. We hide the create vs.
|
||||||
|
// update branching here so the modal stays mode-agnostic.
|
||||||
|
async function onSave(payload) {
|
||||||
|
if (formMode.value === 'edit' && formNode.value?.id) {
|
||||||
|
return update(formNode.value.id, payload);
|
||||||
|
}
|
||||||
|
return create(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDelete(node) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('pages.nodes.deleteConfirmTitle', { name: node.name }),
|
||||||
|
content: t('pages.nodes.deleteConfirmContent'),
|
||||||
|
okText: t('delete'),
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await remove(node.id);
|
||||||
|
if (msg?.success) message.success(t('pages.nodes.toasts.deleted'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onProbe(node) {
|
||||||
|
const msg = await probe(node.id);
|
||||||
|
if (msg?.success && msg.obj) {
|
||||||
|
if (msg.obj.status === 'online') {
|
||||||
|
message.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
|
||||||
|
} else {
|
||||||
|
message.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleEnable(node, next) {
|
||||||
|
await setEnable(node.id, next);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-config-provider :theme="antdThemeConfig">
|
||||||
|
<a-layout class="nodes-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||||
|
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||||
|
|
||||||
|
<a-layout class="content-shell">
|
||||||
|
<a-layout-content id="content-layout" class="content-area">
|
||||||
|
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
||||||
|
<div v-if="!fetched" class="loading-spacer" />
|
||||||
|
|
||||||
|
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
|
||||||
|
<!-- Summary statistics card -->
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-card size="small" hoverable class="summary-card">
|
||||||
|
<a-row :gutter="[16, 12]">
|
||||||
|
<a-col :sm="12" :md="6">
|
||||||
|
<CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)">
|
||||||
|
<template #prefix>
|
||||||
|
<CloudServerOutlined />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :sm="12" :md="6">
|
||||||
|
<CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)">
|
||||||
|
<template #prefix>
|
||||||
|
<CheckCircleOutlined style="color: #52c41a" />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :sm="12" :md="6">
|
||||||
|
<CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)">
|
||||||
|
<template #prefix>
|
||||||
|
<CloseCircleOutlined style="color: #ff4d4f" />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :sm="12" :md="6">
|
||||||
|
<CustomStatistic :title="t('pages.nodes.avgLatency')"
|
||||||
|
:value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'">
|
||||||
|
<template #prefix>
|
||||||
|
<ThunderboltOutlined />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- Node table -->
|
||||||
|
<a-col :span="24">
|
||||||
|
<NodeList :nodes="nodes" :loading="loading" :is-mobile="isMobile" @add="onAdd" @edit="onEdit"
|
||||||
|
@delete="onDelete" @probe="onProbe" @toggle-enable="onToggleEnable" />
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-spin>
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
|
||||||
|
<NodeFormModal v-model:open="formOpen" :mode="formMode" :node="formNode" :test-connection="testConnection"
|
||||||
|
:save="onSave" />
|
||||||
|
</a-layout>
|
||||||
|
</a-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nodes-page {
|
||||||
|
--bg-page: #e6e8ec;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodes-page.is-dark {
|
||||||
|
--bg-page: #1e1e1e;
|
||||||
|
--bg-card: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodes-page.is-dark.is-ultra {
|
||||||
|
--bg-page: #050505;
|
||||||
|
--bg-card: #0c0e12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodes-page :deep(.ant-layout),
|
||||||
|
.nodes-page :deep(.ant-layout-content) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-shell {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content-area {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spacer {
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.summary-card {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Loads the node list and runs CRUD/probe actions against the
|
||||||
|
// /panel/api/nodes/* endpoints. Live updates arrive over WebSocket
|
||||||
|
// (pushed by NodeHeartbeatJob every 10s) so we don't poll.
|
||||||
|
|
||||||
|
import { computed, onMounted, ref, shallowRef } from 'vue';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
||||||
|
export function useNodes() {
|
||||||
|
const nodes = shallowRef([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const fetched = ref(false);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/nodes/list');
|
||||||
|
if (msg?.success) {
|
||||||
|
nodes.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||||
|
}
|
||||||
|
fetched.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replaces the local list with the snapshot pushed by the heartbeat job.
|
||||||
|
// shallowRef means a fresh assignment is enough to retrigger reactivity;
|
||||||
|
// we always assign a new array so Vue notices.
|
||||||
|
function applyNodesEvent(payload) {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
nodes.value = payload;
|
||||||
|
if (!fetched.value) fetched.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload) {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/nodes/add', payload);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id, payload) {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/nodes/update/${id}`, payload);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id) {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/nodes/del/${id}`);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setEnable(id, enable) {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable });
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// testConnection probes a transient (unsaved) node config so the form
|
||||||
|
// can validate before save. Returns the ProbeResultUI shape from Go.
|
||||||
|
async function testConnection(payload) {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/nodes/test', payload);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// probe forces an immediate heartbeat against an already-saved node.
|
||||||
|
async function probe(id) {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/nodes/probe/${id}`);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate cards on the dashboard. Computed off the live list so a
|
||||||
|
// refresh (or a WS push) picks up new totals automatically.
|
||||||
|
const totals = computed(() => {
|
||||||
|
const list = nodes.value;
|
||||||
|
let online = 0;
|
||||||
|
let offline = 0;
|
||||||
|
let latencySum = 0;
|
||||||
|
let latencyCount = 0;
|
||||||
|
for (const n of list) {
|
||||||
|
if (!n.enable) continue;
|
||||||
|
if (n.status === 'online') {
|
||||||
|
online += 1;
|
||||||
|
if (n.latencyMs > 0) {
|
||||||
|
latencySum += n.latencyMs;
|
||||||
|
latencyCount += 1;
|
||||||
|
}
|
||||||
|
} else if (n.status === 'offline') {
|
||||||
|
offline += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total: list.length,
|
||||||
|
online,
|
||||||
|
offline,
|
||||||
|
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial fetch — WebSocket takes over after the first heartbeat tick
|
||||||
|
// (~10s) but the page should populate immediately on mount.
|
||||||
|
onMounted(refresh);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
loading,
|
||||||
|
fetched,
|
||||||
|
totals,
|
||||||
|
refresh,
|
||||||
|
applyNodesEvent,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
setEnable,
|
||||||
|
testConnection,
|
||||||
|
probe,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user