mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-16 09:36:07 +00:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7035b56fe | |||
| 5f526e5201 | |||
| bd8d33980f | |||
| 5dc02a9af3 | |||
| 033c5993e0 | |||
| 2204c8231d | |||
| 01a7dc807b | |||
| 6bf4a2c4f0 | |||
| 21058eb63c | |||
| 194de8869e | |||
| 26accfd8f7 | |||
| 2551a673c3 | |||
| ce4c42e09c | |||
| 18614bd6ea | |||
| e564c9283d | |||
| 933567d423 | |||
| 771bc7c8ef | |||
| 61ab602887 | |||
| adc262a238 | |||
| 67b098dfd3 | |||
| 5543466fcc | |||
| b10a9f1de7 | |||
| 4399fe2a85 | |||
| 6c6b40e063 | |||
| b97ff40ad6 | |||
| 46b6f8c66c | |||
| 102df7a290 | |||
| f29c8a5e29 | |||
| ad81649c16 | |||
| b47f794ed0 | |||
| 4e1b597914 | |||
| bbefe91011 | |||
| e40554a7d5 | |||
| 3569b1be73 | |||
| 38da210ded | |||
| 9fc47b3d41 | |||
| 210c25cf13 | |||
| 5dd7e44594 | |||
| 4e4a8e9ff7 | |||
| 23970e72a7 | |||
| 8bdb093d6e | |||
| 3b0bcb910e | |||
| 428f1333ac | |||
| 406cb6dbc0 | |||
| 5fb36d34c9 | |||
| 4884a2972a | |||
| 6e12329d9d | |||
| 9f7e8178d4 | |||
| 60e6b12f4c | |||
| 0dbadf82c0 | |||
| 48e90bba51 | |||
| 6de9b24229 | |||
| 07bc74a521 | |||
| f570b991e7 | |||
| 80031e67cc | |||
| fdaa65ad7e | |||
| d86e87ed30 | |||
| 89a8f549f2 | |||
| 355bb4c9c0 | |||
| 9feeccffc0 | |||
| cb962175c2 | |||
| 8f3202f431 | |||
| 0cb6568fd5 | |||
| 6a90f98412 | |||
| 9318c2105f | |||
| e642f7324e | |||
| 7214ffafc5 | |||
| 88061bac10 | |||
| b5479f3f30 | |||
| d8aedcdde4 | |||
| 5f3e9ed0ea | |||
| 3e8a0eb93e | |||
| 4c2915586c | |||
| 9f06bffbea | |||
| e20d73ba7e | |||
| 8834e5fbbe | |||
| 6d732d8d32 | |||
| e4900f1bd4 | |||
| 04828246fc | |||
| c1efc48694 | |||
| f1760b0a28 | |||
| 745e394c74 | |||
| 737300b14b | |||
| 30469fcd10 | |||
| 887fca86ec |
@@ -9,3 +9,11 @@ updates:
|
|||||||
directory: "/" # Location of package manifests
|
directory: "/" # Location of package manifests
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "gomod"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/frontend"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "**.go"
|
||||||
|
- "go.mod"
|
||||||
|
- "go.sum"
|
||||||
|
- "**.js"
|
||||||
|
- "**.mjs"
|
||||||
|
- "**.cjs"
|
||||||
|
- "**.ts"
|
||||||
|
- "**.vue"
|
||||||
|
- "**.html"
|
||||||
|
- "**.css"
|
||||||
|
- "frontend/package.json"
|
||||||
|
- "frontend/package-lock.json"
|
||||||
|
- ".nvmrc"
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "**.go"
|
||||||
|
- "go.mod"
|
||||||
|
- "go.sum"
|
||||||
|
- "**.js"
|
||||||
|
- "**.mjs"
|
||||||
|
- "**.cjs"
|
||||||
|
- "**.ts"
|
||||||
|
- "**.vue"
|
||||||
|
- "**.html"
|
||||||
|
- "**.css"
|
||||||
|
- "frontend/package.json"
|
||||||
|
- "frontend/package-lock.json"
|
||||||
|
- ".nvmrc"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
go-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
- name: Stub web/dist for go:embed
|
||||||
|
run: mkdir -p web/dist && touch web/dist/.gitkeep
|
||||||
|
- name: Test
|
||||||
|
run: |
|
||||||
|
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
|
||||||
|
go test $(cat /tmp/go-packages.txt)
|
||||||
|
|
||||||
|
govulncheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
- name: Stub web/dist for go:embed
|
||||||
|
run: mkdir -p web/dist && touch web/dist/.gitkeep
|
||||||
|
- name: Install govulncheck
|
||||||
|
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
- name: Run govulncheck
|
||||||
|
run: govulncheck ./...
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- name: Install
|
||||||
|
run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
working-directory: frontend
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
working-directory: frontend
|
||||||
|
- name: Audit
|
||||||
|
run: npm audit --audit-level=high
|
||||||
|
working-directory: frontend
|
||||||
@@ -2,9 +2,31 @@ name: "CodeQL Advanced"
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
tags-ignore:
|
tags-ignore:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
paths:
|
||||||
|
- "**.go"
|
||||||
|
- "go.mod"
|
||||||
|
- "go.sum"
|
||||||
|
- "**.js"
|
||||||
|
- "**.mjs"
|
||||||
|
- "**.cjs"
|
||||||
|
- "**.ts"
|
||||||
|
- "**.vue"
|
||||||
|
- "frontend/package-lock.json"
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "**.go"
|
||||||
|
- "go.mod"
|
||||||
|
- "go.sum"
|
||||||
|
- "**.js"
|
||||||
|
- "**.mjs"
|
||||||
|
- "**.cjs"
|
||||||
|
- "**.ts"
|
||||||
|
- "**.vue"
|
||||||
|
- "frontend/package-lock.json"
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "18 2 * * 2"
|
- cron: "18 2 * * 2"
|
||||||
|
|
||||||
@@ -35,9 +57,6 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
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
|
- name: Setup Node.js
|
||||||
if: matrix.language == 'go'
|
if: matrix.language == 'go'
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ on:
|
|||||||
- "x-ui.service.arch"
|
- "x-ui.service.arch"
|
||||||
- "x-ui.service.rhel"
|
- "x-ui.service.rhel"
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "**.js"
|
||||||
|
- "**.css"
|
||||||
|
- "**.html"
|
||||||
|
- "**.sh"
|
||||||
|
- "**.go"
|
||||||
|
- "go.mod"
|
||||||
|
- "go.sum"
|
||||||
|
- "x-ui.service.debian"
|
||||||
|
- "x-ui.service.arch"
|
||||||
|
- "x-ui.service.rhel"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|||||||
+3
-3
@@ -22,7 +22,7 @@ EOF
|
|||||||
|
|
||||||
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
|
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
|
||||||
[Definition]
|
[Definition]
|
||||||
datepattern = ^%Y/%m/%d %H:%M:%S
|
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+
|
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 =
|
ignoreregex =
|
||||||
EOF
|
EOF
|
||||||
@@ -43,10 +43,10 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
|||||||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||||
|
|
||||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
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
|
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>
|
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
|
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||||
|
|
||||||
[Init]
|
[Init]
|
||||||
name = default
|
name = default
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.0.0
|
3.0.2
|
||||||
+90
-26
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/config"
|
"github.com/mhsanaei/3x-ui/v3/config"
|
||||||
@@ -40,9 +41,14 @@ func initModels() error {
|
|||||||
&model.HistoryOfSeeders{},
|
&model.HistoryOfSeeders{},
|
||||||
&model.CustomGeoResource{},
|
&model.CustomGeoResource{},
|
||||||
&model.Node{},
|
&model.Node{},
|
||||||
|
&model.ApiToken{},
|
||||||
}
|
}
|
||||||
for _, model := range models {
|
for _, mdl := range models {
|
||||||
if err := db.AutoMigrate(model); err != nil {
|
if err := db.AutoMigrate(mdl); err != nil {
|
||||||
|
if isIgnorableDuplicateColumnErr(err, mdl) {
|
||||||
|
log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
log.Printf("Error auto migrating model: %v", err)
|
log.Printf("Error auto migrating model: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -50,6 +56,27 @@ func initModels() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
errMsg := strings.ToLower(err.Error())
|
||||||
|
const dupPrefix = "duplicate column name:"
|
||||||
|
if !strings.Contains(errMsg, dupPrefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
idx := strings.Index(errMsg, dupPrefix)
|
||||||
|
if idx < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
col := strings.TrimSpace(errMsg[idx+len(dupPrefix):])
|
||||||
|
col = strings.Trim(col, "`\"[]")
|
||||||
|
if col == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return db != nil && db.Migrator().HasColumn(mdl, col)
|
||||||
|
}
|
||||||
|
|
||||||
// initUser creates a default admin user if the users table is empty.
|
// initUser creates a default admin user if the users table is empty.
|
||||||
func initUser() error {
|
func initUser() error {
|
||||||
empty, err := isTableEmpty("users")
|
empty, err := isTableEmpty("users")
|
||||||
@@ -86,43 +113,80 @@ func runSeeders(isUsersEmpty bool) error {
|
|||||||
hashSeeder := &model.HistoryOfSeeders{
|
hashSeeder := &model.HistoryOfSeeders{
|
||||||
SeederName: "UserPasswordHash",
|
SeederName: "UserPasswordHash",
|
||||||
}
|
}
|
||||||
return db.Create(hashSeeder).Error
|
if err := db.Create(hashSeeder).Error; err != nil {
|
||||||
} else {
|
return err
|
||||||
var seedersHistory []string
|
}
|
||||||
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
|
return seedApiTokens()
|
||||||
log.Printf("Error fetching seeder history: %v", err)
|
}
|
||||||
|
|
||||||
|
var seedersHistory []string
|
||||||
|
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 {
|
||||||
|
var users []model.User
|
||||||
|
if err := db.Find(&users).Error; err != nil {
|
||||||
|
log.Printf("Error fetching users for password migration: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
|
for _, user := range users {
|
||||||
var users []model.User
|
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
||||||
if err := db.Find(&users).Error; err != nil {
|
if err != nil {
|
||||||
log.Printf("Error fetching users for password migration: %v", err)
|
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
|
||||||
for _, user := range users {
|
log.Printf("Error updating password for user '%s': %v", user.Username, err)
|
||||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
return err
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
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{
|
||||||
SeederName: "UserPasswordHash",
|
SeederName: "UserPasswordHash",
|
||||||
}
|
}
|
||||||
return db.Create(hashSeeder).Error
|
if err := db.Create(hashSeeder).Error; err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(seedersHistory, "ApiTokensTable") {
|
||||||
|
if err := seedApiTokens(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedApiTokens copies the legacy `apiToken` setting into the new
|
||||||
|
// api_tokens table as a row named "default" so existing central panels
|
||||||
|
// keep working after the upgrade. Idempotent — records itself in
|
||||||
|
// history_of_seeders and only runs when api_tokens is empty.
|
||||||
|
func seedApiTokens() error {
|
||||||
|
empty, err := isTableEmpty("api_tokens")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if empty {
|
||||||
|
var legacy model.Setting
|
||||||
|
err := db.Model(model.Setting{}).Where("key = ?", "apiToken").First(&legacy).Error
|
||||||
|
if err == nil && legacy.Value != "" {
|
||||||
|
row := &model.ApiToken{
|
||||||
|
Name: "default",
|
||||||
|
Token: legacy.Value,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := db.Create(row).Error; err != nil {
|
||||||
|
log.Printf("Error migrating legacy apiToken: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
|
||||||
|
}
|
||||||
|
|
||||||
// isTableEmpty returns true if the named table contains zero rows.
|
// isTableEmpty returns true if the named table contains zero rows.
|
||||||
func isTableEmpty(tableName string) (bool, error) {
|
func isTableEmpty(tableName string) (bool, error) {
|
||||||
var count int64
|
var count int64
|
||||||
|
|||||||
+25
-24
@@ -21,12 +21,8 @@ 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
|
Hysteria Protocol = "hysteria"
|
||||||
// settings.version to discriminate. Imports from outside the panel
|
Hysteria2 Protocol = "hysteria2"
|
||||||
// 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".
|
// IsHysteria returns true for both "hysteria" and "hysteria2".
|
||||||
@@ -38,9 +34,10 @@ func IsHysteria(p Protocol) bool {
|
|||||||
|
|
||||||
// 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"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
LoginEpoch int64 `json:"-" gorm:"default:0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
||||||
@@ -66,12 +63,7 @@ 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 *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||||
// 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.
|
||||||
@@ -96,6 +88,14 @@ type HistoryOfSeeders struct {
|
|||||||
SeederName string `json:"seederName"`
|
SeederName string `json:"seederName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiToken struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||||
|
Token string `json:"token" gorm:"not null"`
|
||||||
|
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||||
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -128,15 +128,16 @@ type Setting struct {
|
|||||||
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
||||||
// status fields below.
|
// status fields below.
|
||||||
type Node struct {
|
type Node struct {
|
||||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
|
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
|
||||||
Remark string `json:"remark" form:"remark"`
|
Remark string `json:"remark" form:"remark"`
|
||||||
Scheme string `json:"scheme" form:"scheme"`
|
Scheme string `json:"scheme" form:"scheme"`
|
||||||
Address string `json:"address" form:"address"`
|
Address string `json:"address" form:"address"`
|
||||||
Port int `json:"port" form:"port"`
|
Port int `json:"port" form:"port"`
|
||||||
BasePath string `json:"basePath" form:"basePath"`
|
BasePath string `json:"basePath" form:"basePath"`
|
||||||
ApiToken string `json:"apiToken" form:"apiToken"`
|
ApiToken string `json:"apiToken" form:"apiToken"`
|
||||||
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
||||||
|
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
||||||
|
|
||||||
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
||||||
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
||||||
|
|||||||
+2
-1
@@ -12,5 +12,6 @@ services:
|
|||||||
XRAY_VMESS_AEAD_FORCED: "false"
|
XRAY_VMESS_AEAD_FORCED: "false"
|
||||||
XUI_ENABLE_FAIL2BAN: "true"
|
XUI_ENABLE_FAIL2BAN: "true"
|
||||||
tty: true
|
tty: true
|
||||||
network_mode: host
|
ports:
|
||||||
|
- "2053:2053"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -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>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>
|
||||||
@@ -19,9 +19,6 @@ export default [
|
|||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
...globals.node,
|
...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: {
|
rules: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>3x-ui · Inbounds</title>
|
<title>Inbounds</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="message"></div>
|
<div id="message"></div>
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>3x-ui</title>
|
<title>Overview</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="message"></div>
|
<div id="message"></div>
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="robots" content="noindex,nofollow" />
|
<meta name="robots" content="noindex,nofollow" />
|
||||||
<title>3x-ui — Sign in</title>
|
<title>Sign in</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="message"></div>
|
<div id="message"></div>
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>3x-ui · Nodes</title>
|
<title>Nodes</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="message"></div>
|
<div id="message"></div>
|
||||||
|
|||||||
Generated
+616
-202
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "3x-ui-frontend",
|
"name": "3x-ui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
|
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0",
|
||||||
|
"npm": ">=10.0.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -12,12 +16,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"ant-design-vue": "^4.2.6",
|
"ant-design-vue": "^4.2.6",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"moment": "^2.30.1",
|
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"qrious": "^4.0.2",
|
|
||||||
"qs": "^6.13.1",
|
"qs": "^6.13.1",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "^11.1.4",
|
"vue-i18n": "^11.1.4",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>3x-ui · Settings</title>
|
<title>Settings</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="message"></div>
|
<div id="message"></div>
|
||||||
|
|||||||
@@ -2,27 +2,19 @@ import axios from 'axios';
|
|||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
|
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
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';
|
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 csrfToken = null;
|
||||||
let csrfFetchPromise = null;
|
let csrfFetchPromise = null;
|
||||||
|
let sessionExpired = false;
|
||||||
|
|
||||||
function readMetaToken() {
|
function readMetaToken() {
|
||||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
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() {
|
async function fetchCsrfToken() {
|
||||||
try {
|
try {
|
||||||
const basePath = window.__X_UI_BASE_PATH__;
|
const basePath = window.X_UI_BASE_PATH;
|
||||||
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
||||||
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
|
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
|
||||||
: CSRF_TOKEN_PATH);
|
: CSRF_TOKEN_PATH);
|
||||||
@@ -59,7 +51,7 @@ export function setupAxios() {
|
|||||||
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
||||||
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||||
|
|
||||||
const basePath = window.__X_UI_BASE_PATH__;
|
const basePath = window.X_UI_BASE_PATH;
|
||||||
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
|
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
|
||||||
axios.defaults.baseURL = basePath;
|
axios.defaults.baseURL = basePath;
|
||||||
}
|
}
|
||||||
@@ -91,19 +83,12 @@ export function setupAxios() {
|
|||||||
async (error) => {
|
async (error) => {
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
// 401 → session is gone. In production, the panel routes
|
if (!sessionExpired) {
|
||||||
// are gated by Go's checkLogin which redirects to base_path
|
sessionExpired = true;
|
||||||
// serving the login page; a reload is enough. In dev, Vite
|
const basePath = window.X_UI_BASE_PATH || '/';
|
||||||
// serves /index.html directly at "/", so a reload would put
|
window.location.replace(basePath);
|
||||||
// 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);
|
return new Promise(() => { });
|
||||||
}
|
}
|
||||||
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
|
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
|
||||||
const cfg = error.config;
|
const cfg = error.config;
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export class WebSocketClient {
|
|||||||
|
|
||||||
#buildUrl() {
|
#buildUrl() {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
// basePath comes from window.__X_UI_BASE_PATH__ which is only injected
|
// basePath comes from window.X_UI_BASE_PATH which is only injected
|
||||||
// by the Go binary in production. In dev (Vite serves directly) the
|
// by the Go binary in production. In dev (Vite serves directly) the
|
||||||
// global is missing and basePath would be '' — without the fallback to
|
// global is missing and basePath would be '' — without the fallback to
|
||||||
// '/' we'd build `ws://host:portws` (no separator) and the WebSocket
|
// '/' we'd build `ws://host:portws` (no separator) and the WebSocket
|
||||||
|
|||||||
@@ -9,28 +9,24 @@ import {
|
|||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
MenuFoldOutlined,
|
MenuOutlined,
|
||||||
|
ApiOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
import { currentTheme } from '@/composables/useTheme.js';
|
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||||
import ThemeSwitch from '@/components/ThemeSwitch.vue';
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// Path prefix (e.g. /custom-base/) the panel is served under. Defaults
|
|
||||||
// to '' which means tab keys end up as '/panel/...'. Pages pass the
|
|
||||||
// value the Go backend gave them (in production via a meta tag).
|
|
||||||
basePath: { type: String, default: '' },
|
basePath: { type: String, default: '' },
|
||||||
// Current request URI so the matching menu item highlights.
|
// Current request URI so the matching menu item highlights.
|
||||||
requestUri: { type: String, default: '' },
|
requestUri: { type: String, default: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// AD-Vue 4 dropped <a-icon :type="x"> in favor of explicit icon
|
|
||||||
// imports — keep a small name-to-component map so tab definitions stay
|
|
||||||
// declarative.
|
|
||||||
const iconByName = {
|
const iconByName = {
|
||||||
dashboard: DashboardOutlined,
|
dashboard: DashboardOutlined,
|
||||||
user: UserOutlined,
|
user: UserOutlined,
|
||||||
@@ -38,32 +34,34 @@ const iconByName = {
|
|||||||
tool: ToolOutlined,
|
tool: ToolOutlined,
|
||||||
cluster: ClusterOutlined,
|
cluster: ClusterOutlined,
|
||||||
logout: LogoutOutlined,
|
logout: LogoutOutlined,
|
||||||
|
apidocs: ApiOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// basePath comes from Go (`/` by default, `/myprefix/` when configured) so
|
|
||||||
// these concatenations land on absolute paths. In dev we synthesize the prop
|
|
||||||
// from a window global which can be empty — force a leading slash so the
|
|
||||||
// browser doesn't resolve the link relative to the current pathname (which
|
|
||||||
// would turn /panel/settings + 'panel/...' into /panel/panel/...).
|
|
||||||
const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
|
const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
|
||||||
|
|
||||||
// Labels are i18n-driven so the sidebar matches the locale picked
|
|
||||||
// in panel settings without a page reload of the sidebar component.
|
|
||||||
const tabs = computed(() => [
|
const tabs = computed(() => [
|
||||||
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||||
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
||||||
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
||||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||||
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
|
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
|
||||||
|
{ key: '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 activeTab = ref([props.requestUri]);
|
||||||
|
|
||||||
const drawerOpen = ref(false);
|
const drawerOpen = ref(false);
|
||||||
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
|
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
|
||||||
|
const drawerWidth = 'min(82vw, 320px)';
|
||||||
|
|
||||||
function openLink(key) {
|
async function openLink(key) {
|
||||||
|
if (key === 'logout') {
|
||||||
|
await HttpUtil.post('/logout');
|
||||||
|
window.location.href = props.basePath || '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (key.startsWith('http')) {
|
if (key.startsWith('http')) {
|
||||||
window.open(key);
|
window.open(key);
|
||||||
} else {
|
} else {
|
||||||
@@ -86,14 +84,55 @@ function toggleDrawer() {
|
|||||||
function closeDrawer() {
|
function closeDrawer() {
|
||||||
drawerOpen.value = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ant-sidebar">
|
<div class="ant-sidebar">
|
||||||
<a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
|
<a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
|
||||||
<ThemeSwitch />
|
<div class="sider-brand" :class="{ 'sider-brand-collapsed': collapsed }">
|
||||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
|
<span class="brand-text">{{ collapsed ? '3X' : '3X-UI' }}</span>
|
||||||
<a-menu-item v-for="tab in tabs" :key="tab.key">
|
<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]" />
|
<component :is="iconByName[tab.icon]" />
|
||||||
<span>{{ tab.title }}</span>
|
<span>{{ tab.title }}</span>
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
@@ -101,43 +140,218 @@ function closeDrawer() {
|
|||||||
</a-layout-sider>
|
</a-layout-sider>
|
||||||
|
|
||||||
<a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
|
<a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
|
||||||
:wrap-style="{ padding: 0 }" :style="{ height: '100%' }" @close="closeDrawer">
|
:wrap-style="{ padding: 0 }" :width="drawerWidth"
|
||||||
<ThemeSwitch />
|
:body-style="{ padding: 0, display: 'flex', flexDirection: 'column', height: '100%' }"
|
||||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
|
:header-style="{ display: 'none' }" @close="closeDrawer">
|
||||||
<a-menu-item v-for="tab in tabs" :key="tab.key">
|
<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]" />
|
<component :is="iconByName[tab.icon]" />
|
||||||
<span>{{ tab.title }}</span>
|
<span>{{ tab.title }}</span>
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
</a-menu>
|
</a-menu>
|
||||||
</a-drawer>
|
</a-drawer>
|
||||||
|
|
||||||
<button class="drawer-handle" type="button" @click="toggleDrawer">
|
<button v-show="!drawerOpen" class="drawer-handle" type="button" :aria-label="t('menu.dashboard')"
|
||||||
<CloseOutlined v-if="drawerOpen" />
|
@click="toggleDrawer">
|
||||||
<MenuFoldOutlined v-else />
|
<MenuOutlined />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ant-sidebar>.ant-layout-sider {
|
.ant-sidebar>.ant-layout-sider {
|
||||||
height: 100%;
|
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 {
|
.drawer-handle {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 16px;
|
top: 12px;
|
||||||
left: 16px;
|
left: 12px;
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
width: 36px;
|
width: 40px;
|
||||||
height: 36px;
|
height: 40px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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) {
|
@media (max-width: 768px) {
|
||||||
@@ -145,9 +359,6 @@ function closeDrawer() {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* On mobile the drawer is the menu — hide the inline sider's content
|
|
||||||
* + the collapse trigger so the sider stops taking layout space and
|
|
||||||
* leaves no remnant button next to the page. */
|
|
||||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
|
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
|
||||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
|
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -161,3 +372,58 @@ function closeDrawer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
||||||
|
|||||||
@@ -235,8 +235,8 @@ function onAntChange(next) {
|
|||||||
/* ===== Dark (navy) ======================================================= */
|
/* ===== Dark (navy) ======================================================= */
|
||||||
|
|
||||||
body.dark .persian-datepicker-input {
|
body.dark .persian-datepicker-input {
|
||||||
background: #142340;
|
background: #252526;
|
||||||
border-color: #1f3358;
|
border-color: #3c3c3c;
|
||||||
color: rgba(255, 255, 255, 0.88);
|
color: rgba(255, 255, 255, 0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,14 +251,14 @@ body.dark .persian-datepicker-input:focus {
|
|||||||
|
|
||||||
body.dark .vpd-main .vpd-icon-btn {
|
body.dark .vpd-main .vpd-icon-btn {
|
||||||
background: rgba(255, 255, 255, 0.04) !important;
|
background: rgba(255, 255, 255, 0.04) !important;
|
||||||
border: 1px solid #1f3358 !important;
|
border: 1px solid #3c3c3c !important;
|
||||||
border-right: none !important;
|
border-right: none !important;
|
||||||
border-radius: 6px 0 0 6px !important;
|
border-radius: 6px 0 0 6px !important;
|
||||||
color: rgba(255, 255, 255, 0.75) !important;
|
color: rgba(255, 255, 255, 0.75) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .vpd-wrapper .vpd-content {
|
body.dark .vpd-wrapper .vpd-content {
|
||||||
background: #1a2c4d;
|
background: #2d2d30;
|
||||||
color: rgba(255, 255, 255, 0.88);
|
color: rgba(255, 255, 255, 0.88);
|
||||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
|
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
|
||||||
0 3px 6px -4px rgba(0, 0, 0, 0.48),
|
0 3px 6px -4px rgba(0, 0, 0, 0.48),
|
||||||
@@ -266,7 +266,7 @@ body.dark .vpd-wrapper .vpd-content {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.dark .vpd-wrapper .vpd-body {
|
body.dark .vpd-wrapper .vpd-body {
|
||||||
background: #1a2c4d;
|
background: #2d2d30;
|
||||||
color: rgba(255, 255, 255, 0.88);
|
color: rgba(255, 255, 255, 0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ body.dark .vpd-wrapper .vpd-actions button:hover {
|
|||||||
|
|
||||||
body.dark .vpd-wrapper .vpd-addon-list,
|
body.dark .vpd-wrapper .vpd-addon-list,
|
||||||
body.dark .vpd-wrapper .vpd-addon-list-content {
|
body.dark .vpd-wrapper .vpd-addon-list-content {
|
||||||
background: #1a2c4d;
|
background: #2d2d30;
|
||||||
color: rgba(255, 255, 255, 0.88);
|
color: rgba(255, 255, 255, 0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { EditorView, basicSetup } from 'codemirror';
|
||||||
|
import { EditorState, Compartment } from '@codemirror/state';
|
||||||
|
import { json, jsonParseLinter } from '@codemirror/lang-json';
|
||||||
|
import { lintGutter, linter } from '@codemirror/lint';
|
||||||
|
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||||
|
import { syntaxHighlighting } from '@codemirror/language';
|
||||||
|
import { keymap } from '@codemirror/view';
|
||||||
|
import { indentWithTab } from '@codemirror/commands';
|
||||||
|
|
||||||
|
import { theme as themeState } from '@/composables/useTheme.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: { type: String, default: '' },
|
||||||
|
minHeight: { type: String, default: '320px' },
|
||||||
|
maxHeight: { type: String, default: '600px' },
|
||||||
|
readonly: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:value', 'change']);
|
||||||
|
|
||||||
|
const host = ref(null);
|
||||||
|
let view = null;
|
||||||
|
const themeCompartment = new Compartment();
|
||||||
|
const readonlyCompartment = new Compartment();
|
||||||
|
|
||||||
|
function buildDarkTheme({ bg, panelBg, activeBg, border, selection }) {
|
||||||
|
return EditorView.theme(
|
||||||
|
{
|
||||||
|
'&': { color: '#dcdcdc', backgroundColor: bg },
|
||||||
|
'.cm-content': { caretColor: '#dcdcdc' },
|
||||||
|
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: bg,
|
||||||
|
borderRight: `1px solid ${border}`,
|
||||||
|
color: '#6a6a6a',
|
||||||
|
},
|
||||||
|
'.cm-activeLine': { backgroundColor: activeBg },
|
||||||
|
'.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
|
||||||
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||||
|
{ backgroundColor: selection },
|
||||||
|
'.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
|
||||||
|
'.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
|
||||||
|
'.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
|
||||||
|
'.cm-tooltip': {
|
||||||
|
backgroundColor: panelBg,
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
color: '#dcdcdc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ dark: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkTheme = buildDarkTheme({
|
||||||
|
bg: '#1e1e1e',
|
||||||
|
panelBg: '#2d2d30',
|
||||||
|
activeBg: '#252526',
|
||||||
|
border: '#3a3a3c',
|
||||||
|
selection: '#3a3a3c',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ultraDarkTheme = buildDarkTheme({
|
||||||
|
bg: '#0a0a0a',
|
||||||
|
panelBg: '#141414',
|
||||||
|
activeBg: '#141414',
|
||||||
|
border: '#1f1f1f',
|
||||||
|
selection: '#2a2a2a',
|
||||||
|
});
|
||||||
|
|
||||||
|
function themeExtension() {
|
||||||
|
if (!themeState.isDark) return [];
|
||||||
|
const chrome = themeState.isUltra ? ultraDarkTheme : darkTheme;
|
||||||
|
return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readonlyExtension() {
|
||||||
|
return EditorState.readOnly.of(props.readonly);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const updateListener = EditorView.updateListener.of((u) => {
|
||||||
|
if (!u.docChanged) return;
|
||||||
|
const next = u.state.doc.toString();
|
||||||
|
if (next === props.value) return;
|
||||||
|
emit('update:value', next);
|
||||||
|
emit('change', next);
|
||||||
|
});
|
||||||
|
|
||||||
|
view = new EditorView({
|
||||||
|
parent: host.value,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: props.value || '',
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
keymap.of([indentWithTab]),
|
||||||
|
json(),
|
||||||
|
linter(jsonParseLinter()),
|
||||||
|
lintGutter(),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
updateListener,
|
||||||
|
themeCompartment.of(themeExtension()),
|
||||||
|
readonlyCompartment.of(readonlyExtension()),
|
||||||
|
EditorView.theme({
|
||||||
|
'&': { height: '100%' },
|
||||||
|
'.cm-scroller': {
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
minHeight: props.minHeight,
|
||||||
|
maxHeight: props.maxHeight,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.value, (next) => {
|
||||||
|
if (!view) return;
|
||||||
|
const current = view.state.doc.toString();
|
||||||
|
if (next === current) return;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: current.length, insert: next || '' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => themeState.isDark, () => themeState.isUltra],
|
||||||
|
() => {
|
||||||
|
if (!view) return;
|
||||||
|
view.dispatch({ effects: themeCompartment.reconfigure(themeExtension()) });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.readonly,
|
||||||
|
() => {
|
||||||
|
if (!view) return;
|
||||||
|
view.dispatch({ effects: readonlyCompartment.reconfigure(readonlyExtension()) });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
view?.destroy();
|
||||||
|
view = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focus: () => view?.focus(),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="host" class="json-editor-host" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.json-editor-host {
|
||||||
|
border: 1px solid var(--ant-color-border, #d9d9d9);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ant-color-bg-container, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-host :deep(.cm-editor),
|
||||||
|
.json-editor-host :deep(.cm-editor.cm-focused) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-host:focus-within {
|
||||||
|
border-color: var(--ant-color-primary, #1677ff);
|
||||||
|
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .json-editor-host {
|
||||||
|
border-color: #3a3a3c;
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-theme="ultra-dark"]) .json-editor-host {
|
||||||
|
border-color: #1f1f1f;
|
||||||
|
background: #0a0a0a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,7 +17,7 @@ const props = defineProps({
|
|||||||
showAxes: { type: Boolean, default: false },
|
showAxes: { type: Boolean, default: false },
|
||||||
yTickStep: { type: Number, default: 25 },
|
yTickStep: { type: Number, default: 25 },
|
||||||
tickCountX: { type: Number, default: 4 },
|
tickCountX: { type: Number, default: 4 },
|
||||||
paddingLeft: { type: Number, default: 32 },
|
paddingLeft: { type: Number, default: 56 },
|
||||||
paddingRight: { type: Number, default: 6 },
|
paddingRight: { type: Number, default: 6 },
|
||||||
paddingTop: { type: Number, default: 6 },
|
paddingTop: { type: Number, default: 6 },
|
||||||
paddingBottom: { type: Number, default: 20 },
|
paddingBottom: { type: Number, default: 20 },
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { BulbFilled, BulbOutlined } from '@ant-design/icons-vue';
|
|
||||||
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const BulbIcon = computed(() => (theme.isDark ? BulbFilled : BulbOutlined));
|
|
||||||
|
|
||||||
function onDarkChange() {
|
|
||||||
pauseAnimationsUntilLeave('change-theme');
|
|
||||||
toggleTheme();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUltraClick() {
|
|
||||||
pauseAnimationsUntilLeave('change-theme-ultra');
|
|
||||||
toggleUltra();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="[]">
|
|
||||||
<a-sub-menu>
|
|
||||||
<template #title>
|
|
||||||
<span>
|
|
||||||
<component :is="BulbIcon" />
|
|
||||||
<span class="theme-label">{{ t('menu.theme') }}</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<a-menu-item id="change-theme" class="ant-menu-theme-switch">
|
|
||||||
<span>{{ t('menu.dark') }}</span>
|
|
||||||
<a-switch :style="{ marginLeft: '2px' }" size="small" :checked="theme.isDark" @change="onDarkChange" />
|
|
||||||
</a-menu-item>
|
|
||||||
|
|
||||||
<a-menu-item v-if="theme.isDark" id="change-theme-ultra" class="ant-menu-theme-switch">
|
|
||||||
<span>{{ t('menu.ultraDark') }}</span>
|
|
||||||
<a-checkbox :style="{ marginLeft: '2px' }" :checked="theme.isUltra" @click="onUltraClick" />
|
|
||||||
</a-menu-item>
|
|
||||||
</a-sub-menu>
|
|
||||||
</a-menu>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.theme-label {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { theme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
|
||||||
|
|
||||||
function onDarkChange() {
|
|
||||||
pauseAnimationsUntilLeave('change-theme');
|
|
||||||
toggleTheme();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUltraClick() {
|
|
||||||
toggleUltra();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-space id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }">
|
|
||||||
<a-space direction="horizontal" size="small">
|
|
||||||
<a-switch size="small" :checked="theme.isDark" @change="onDarkChange" />
|
|
||||||
<span>Dark</span>
|
|
||||||
</a-space>
|
|
||||||
<a-space v-if="theme.isDark" direction="horizontal" size="small">
|
|
||||||
<a-checkbox :checked="theme.isUltra" @click="onUltraClick" />
|
|
||||||
<span>Ultra dark</span>
|
|
||||||
</a-space>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
@@ -36,7 +36,9 @@ export function useNodeList() {
|
|||||||
return n != null && n.enable && n.status === 'online';
|
return n != null && n.enable && n.status === 'online';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasActive = computed(() => nodes.value.some((n) => n.enable));
|
||||||
|
|
||||||
onMounted(refresh);
|
onMounted(refresh);
|
||||||
|
|
||||||
return { nodes, fetched, refresh, byId, nameFor, isOnline };
|
return { nodes, fetched, refresh, byId, nameFor, isOnline, hasActive };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,14 +27,15 @@ export const currentTheme = computed(() => (theme.isDark ? 'dark' : 'light'));
|
|||||||
|
|
||||||
// AD-Vue 4 theme config consumed by every page's <a-config-provider>.
|
// 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
|
// Three modes — light / dark / ultra-dark — all share AD-Vue's vanilla
|
||||||
// blue primary. Dark uses a navy palette across page/cards/modals so
|
// blue primary. Dark uses a neutral grey palette modelled on VS Code's
|
||||||
// the sidebar blends with the rest of the surface; ultra-dark stays
|
// Dark+ chrome (`#1e1e1e` editor, `#252526` sidebar, `#2d2d30` panel),
|
||||||
// neutral black on top of darkAlgorithm.
|
// 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 = {
|
const DARK_TOKENS = {
|
||||||
colorBgBase: '#0a1426',
|
colorBgBase: '#1e1e1e',
|
||||||
colorBgLayout: '#0a1426',
|
colorBgLayout: '#1e1e1e',
|
||||||
colorBgContainer: '#142340',
|
colorBgContainer: '#252526',
|
||||||
colorBgElevated: '#1a2c4d',
|
colorBgElevated: '#2d2d30',
|
||||||
};
|
};
|
||||||
const ULTRA_DARK_TOKENS = {
|
const ULTRA_DARK_TOKENS = {
|
||||||
colorBgBase: '#000',
|
colorBgBase: '#000',
|
||||||
@@ -47,13 +48,12 @@ const ULTRA_DARK_TOKENS = {
|
|||||||
// + trigger backgrounds and `#001529` / `#000c17` as the dark Menu item
|
// + trigger backgrounds and `#001529` / `#000c17` as the dark Menu item
|
||||||
// backgrounds (see node_modules/ant-design-vue/es/{layout,menu}/style/
|
// backgrounds (see node_modules/ant-design-vue/es/{layout,menu}/style/
|
||||||
// index.js). Override at the component-token level so the sider blends
|
// index.js). Override at the component-token level so the sider blends
|
||||||
// with darkAlgorithm's neutral surfaces.
|
// with darkAlgorithm's neutral surfaces. Sider/trigger use the same
|
||||||
// Dark theme uses a refined navy for the sidebar — distinct from the
|
// `#252526` / `#333333` tones VS Code does for its activity bar.
|
||||||
// neutral ultra-dark and warmer than AD-Vue's stock #001529.
|
|
||||||
const DARK_LAYOUT_TOKENS = {
|
const DARK_LAYOUT_TOKENS = {
|
||||||
colorBgHeader: '#0d1d33',
|
colorBgHeader: '#252526',
|
||||||
colorBgTrigger: '#15294a',
|
colorBgTrigger: '#333333',
|
||||||
colorBgBody: '#000',
|
colorBgBody: '#1e1e1e',
|
||||||
};
|
};
|
||||||
const ULTRA_DARK_LAYOUT_TOKENS = {
|
const ULTRA_DARK_LAYOUT_TOKENS = {
|
||||||
colorBgHeader: '#0a0a0a',
|
colorBgHeader: '#0a0a0a',
|
||||||
@@ -61,9 +61,9 @@ const ULTRA_DARK_LAYOUT_TOKENS = {
|
|||||||
colorBgBody: '#000',
|
colorBgBody: '#000',
|
||||||
};
|
};
|
||||||
const DARK_MENU_TOKENS = {
|
const DARK_MENU_TOKENS = {
|
||||||
colorItemBg: '#0d1d33',
|
colorItemBg: '#252526',
|
||||||
colorSubItemBg: '#08142a',
|
colorSubItemBg: '#1e1e1e',
|
||||||
menuSubMenuBg: '#0d1d33',
|
menuSubMenuBg: '#252526',
|
||||||
};
|
};
|
||||||
const ULTRA_DARK_MENU_TOKENS = {
|
const ULTRA_DARK_MENU_TOKENS = {
|
||||||
colorItemBg: '#0a0a0a',
|
colorItemBg: '#0a0a0a',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ let sharedClient = null;
|
|||||||
|
|
||||||
function getSharedClient() {
|
function getSharedClient() {
|
||||||
if (sharedClient) return sharedClient;
|
if (sharedClient) return sharedClient;
|
||||||
const basePath = (typeof window !== 'undefined' && window.__X_UI_BASE_PATH__) || '';
|
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||||
sharedClient = new WebSocketClient(basePath);
|
sharedClient = new WebSocketClient(basePath);
|
||||||
return sharedClient;
|
return sharedClient;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
import '@/composables/useTheme.js';
|
||||||
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import { applyDocumentTitle } from '@/utils';
|
||||||
|
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
|
||||||
|
|
||||||
|
setupAxios();
|
||||||
|
applyDocumentTitle();
|
||||||
|
|
||||||
|
const messageContainer = document.getElementById('message');
|
||||||
|
if (messageContainer) {
|
||||||
|
message.config({ getContainer: () => messageContainer });
|
||||||
|
}
|
||||||
|
|
||||||
|
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
|
||||||
@@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
|
|||||||
import { setupAxios } from '@/api/axios-init.js';
|
import { setupAxios } from '@/api/axios-init.js';
|
||||||
import '@/composables/useTheme.js';
|
import '@/composables/useTheme.js';
|
||||||
import { i18n } from '@/i18n/index.js';
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import { applyDocumentTitle } from '@/utils';
|
||||||
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
|
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
|
||||||
|
|
||||||
setupAxios();
|
setupAxios();
|
||||||
|
applyDocumentTitle();
|
||||||
|
|
||||||
const messageContainer = document.getElementById('message');
|
const messageContainer = document.getElementById('message');
|
||||||
if (messageContainer) {
|
if (messageContainer) {
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
|
|||||||
// stored theme to <body>/<html> before Vue mounts.
|
// stored theme to <body>/<html> before Vue mounts.
|
||||||
import '@/composables/useTheme.js';
|
import '@/composables/useTheme.js';
|
||||||
import { i18n } from '@/i18n/index.js';
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import { applyDocumentTitle } from '@/utils';
|
||||||
import IndexPage from '@/pages/index/IndexPage.vue';
|
import IndexPage from '@/pages/index/IndexPage.vue';
|
||||||
|
|
||||||
setupAxios();
|
setupAxios();
|
||||||
|
applyDocumentTitle();
|
||||||
|
|
||||||
const messageContainer = document.getElementById('message');
|
const messageContainer = document.getElementById('message');
|
||||||
if (messageContainer) {
|
if (messageContainer) {
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
|
|||||||
// stored theme to <body>/<html> before Vue renders anything.
|
// stored theme to <body>/<html> before Vue renders anything.
|
||||||
import '@/composables/useTheme.js';
|
import '@/composables/useTheme.js';
|
||||||
import { i18n } from '@/i18n/index.js';
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import { applyDocumentTitle } from '@/utils';
|
||||||
import LoginPage from '@/pages/login/LoginPage.vue';
|
import LoginPage from '@/pages/login/LoginPage.vue';
|
||||||
|
|
||||||
setupAxios();
|
setupAxios();
|
||||||
|
applyDocumentTitle();
|
||||||
|
|
||||||
// Toasts attach to a #message div the page provides — keeps theme
|
// Toasts attach to a #message div the page provides — keeps theme
|
||||||
// styling in sync with the rest of the panel.
|
// styling in sync with the rest of the panel.
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
|
|||||||
import { setupAxios } from '@/api/axios-init.js';
|
import { setupAxios } from '@/api/axios-init.js';
|
||||||
import '@/composables/useTheme.js';
|
import '@/composables/useTheme.js';
|
||||||
import { i18n } from '@/i18n/index.js';
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import { applyDocumentTitle } from '@/utils';
|
||||||
import NodesPage from '@/pages/nodes/NodesPage.vue';
|
import NodesPage from '@/pages/nodes/NodesPage.vue';
|
||||||
|
|
||||||
setupAxios();
|
setupAxios();
|
||||||
|
applyDocumentTitle();
|
||||||
|
|
||||||
const messageContainer = document.getElementById('message');
|
const messageContainer = document.getElementById('message');
|
||||||
if (messageContainer) {
|
if (messageContainer) {
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
|
|||||||
// stored theme to <body>/<html> before Vue mounts.
|
// stored theme to <body>/<html> before Vue mounts.
|
||||||
import '@/composables/useTheme.js';
|
import '@/composables/useTheme.js';
|
||||||
import { i18n } from '@/i18n/index.js';
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import { applyDocumentTitle } from '@/utils';
|
||||||
import SettingsPage from '@/pages/settings/SettingsPage.vue';
|
import SettingsPage from '@/pages/settings/SettingsPage.vue';
|
||||||
|
|
||||||
setupAxios();
|
setupAxios();
|
||||||
|
applyDocumentTitle();
|
||||||
|
|
||||||
const messageContainer = document.getElementById('message');
|
const messageContainer = document.getElementById('message');
|
||||||
if (messageContainer) {
|
if (messageContainer) {
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
|
|||||||
import { setupAxios } from '@/api/axios-init.js';
|
import { setupAxios } from '@/api/axios-init.js';
|
||||||
import '@/composables/useTheme.js';
|
import '@/composables/useTheme.js';
|
||||||
import { i18n } from '@/i18n/index.js';
|
import { i18n } from '@/i18n/index.js';
|
||||||
|
import { applyDocumentTitle } from '@/utils';
|
||||||
import XrayPage from '@/pages/xray/XrayPage.vue';
|
import XrayPage from '@/pages/xray/XrayPage.vue';
|
||||||
|
|
||||||
setupAxios();
|
setupAxios();
|
||||||
|
applyDocumentTitle();
|
||||||
|
|
||||||
const messageContainer = document.getElementById('message');
|
const messageContainer = document.getElementById('message');
|
||||||
if (messageContainer) {
|
if (messageContainer) {
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ export class DBInbound {
|
|||||||
return this.protocol === Protocols.WIREGUARD;
|
return this.protocol === Protocols.WIREGUARD;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isHysteria() {
|
||||||
|
return this.protocol === Protocols.HYSTERIA;
|
||||||
|
}
|
||||||
|
|
||||||
get address() {
|
get address() {
|
||||||
let address = location.hostname;
|
let address = location.hostname;
|
||||||
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
|
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { ObjectUtil, RandomUtil, Base64, NumberFormatter, SizeFormatter, Wireguard } from '@/utils';
|
import { ObjectUtil, RandomUtil, Base64, NumberFormatter, SizeFormatter, Wireguard } from '@/utils';
|
||||||
|
import { getRandomRealityTarget } from '@/models/reality-targets';
|
||||||
|
|
||||||
export const Protocols = {
|
export const Protocols = {
|
||||||
VMESS: 'vmess',
|
VMESS: 'vmess',
|
||||||
@@ -687,8 +688,9 @@ export class HysteriaMasquerade extends XrayCommonClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static fromJson(json = {}) {
|
static fromJson(json = {}) {
|
||||||
|
const type = ['proxy', 'file', 'string'].includes(json.type) ? json.type : 'proxy';
|
||||||
return new HysteriaMasquerade(
|
return new HysteriaMasquerade(
|
||||||
json.type,
|
type,
|
||||||
json.dir,
|
json.dir,
|
||||||
json.url,
|
json.url,
|
||||||
json.rewriteHost,
|
json.rewriteHost,
|
||||||
@@ -896,9 +898,7 @@ export class RealityStreamSettings extends XrayCommonClass {
|
|||||||
super();
|
super();
|
||||||
// If target/serverNames are not provided, use random values
|
// If target/serverNames are not provided, use random values
|
||||||
if (!target && !serverNames) {
|
if (!target && !serverNames) {
|
||||||
const randomTarget = typeof getRandomRealityTarget !== 'undefined'
|
const randomTarget = getRandomRealityTarget();
|
||||||
? getRandomRealityTarget()
|
|
||||||
: { target: 'www.amazon.com:443', sni: 'www.amazon.com,amazon.com' };
|
|
||||||
target = randomTarget.target;
|
target = randomTarget.target;
|
||||||
serverNames = randomTarget.sni;
|
serverNames = randomTarget.sni;
|
||||||
}
|
}
|
||||||
@@ -1595,6 +1595,10 @@ export class Inbound extends XrayCommonClass {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof xhttp.mode === 'string' && xhttp.mode.length > 0) {
|
||||||
|
extra.mode = xhttp.mode;
|
||||||
|
}
|
||||||
|
|
||||||
const stringFields = [
|
const stringFields = [
|
||||||
"sessionPlacement", "sessionKey",
|
"sessionPlacement", "sessionKey",
|
||||||
"seqPlacement", "seqKey",
|
"seqPlacement", "seqKey",
|
||||||
@@ -2967,37 +2971,45 @@ Inbound.HysteriaSettings.Hysteria = class extends Inbound.ClientBase {
|
|||||||
Inbound.TunnelSettings = class extends Inbound.Settings {
|
Inbound.TunnelSettings = class extends Inbound.Settings {
|
||||||
constructor(
|
constructor(
|
||||||
protocol,
|
protocol,
|
||||||
address,
|
rewriteAddress,
|
||||||
port,
|
rewritePort,
|
||||||
portMap = [],
|
portMap = [],
|
||||||
network = 'tcp,udp',
|
allowedNetwork = 'tcp,udp',
|
||||||
followRedirect = false
|
followRedirect = false
|
||||||
) {
|
) {
|
||||||
super(protocol);
|
super(protocol);
|
||||||
this.address = address;
|
this.rewriteAddress = rewriteAddress;
|
||||||
this.port = port;
|
this.rewritePort = rewritePort;
|
||||||
this.portMap = portMap;
|
this.portMap = portMap;
|
||||||
this.network = network;
|
this.allowedNetwork = allowedNetwork;
|
||||||
this.followRedirect = followRedirect;
|
this.followRedirect = followRedirect;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addPortMap(port = '', target = '') {
|
||||||
|
this.portMap.push({ name: port, value: target });
|
||||||
|
}
|
||||||
|
|
||||||
|
removePortMap(index) {
|
||||||
|
this.portMap.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
static fromJson(json = {}) {
|
static fromJson(json = {}) {
|
||||||
return new Inbound.TunnelSettings(
|
return new Inbound.TunnelSettings(
|
||||||
Protocols.TUNNEL,
|
Protocols.TUNNEL,
|
||||||
json.address,
|
json.rewriteAddress,
|
||||||
json.port,
|
json.rewritePort,
|
||||||
XrayCommonClass.toHeaders(json.portMap),
|
XrayCommonClass.toHeaders(json.portMap),
|
||||||
json.network,
|
json.allowedNetwork,
|
||||||
json.followRedirect,
|
json.followRedirect,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toJson() {
|
toJson() {
|
||||||
return {
|
return {
|
||||||
address: this.address,
|
rewriteAddress: this.rewriteAddress,
|
||||||
port: this.port,
|
rewritePort: this.rewritePort,
|
||||||
portMap: XrayCommonClass.toV2Headers(this.portMap, false),
|
portMap: XrayCommonClass.toV2Headers(this.portMap, false),
|
||||||
network: this.network,
|
allowedNetwork: this.allowedNetwork,
|
||||||
followRedirect: this.followRedirect,
|
followRedirect: this.followRedirect,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1926,13 +1926,13 @@ Outbound.VmessSettings = class extends CommonClass {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
Outbound.VLESSSettings = class extends CommonClass {
|
Outbound.VLESSSettings = class extends CommonClass {
|
||||||
constructor(address, port, id, flow, encryption, reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = []) {
|
constructor(address, port, id, flow, encryption = 'none', reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = []) {
|
||||||
super();
|
super();
|
||||||
this.address = address;
|
this.address = address;
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.flow = flow;
|
this.flow = flow;
|
||||||
this.encryption = encryption;
|
this.encryption = encryption || 'none';
|
||||||
this.reverseTag = reverseTag;
|
this.reverseTag = reverseTag;
|
||||||
this.reverseSniffing = reverseSniffing;
|
this.reverseSniffing = reverseSniffing;
|
||||||
this.testpre = testpre;
|
this.testpre = testpre;
|
||||||
@@ -1966,7 +1966,7 @@ Outbound.VLESSSettings = class extends CommonClass {
|
|||||||
port: this.port,
|
port: this.port,
|
||||||
id: this.id,
|
id: this.id,
|
||||||
flow: this.flow,
|
flow: this.flow,
|
||||||
encryption: this.encryption,
|
encryption: this.encryption || 'none',
|
||||||
};
|
};
|
||||||
if (!ObjectUtil.isEmpty(this.reverseTag)) {
|
if (!ObjectUtil.isEmpty(this.reverseTag)) {
|
||||||
const reverseSniffing = this.reverseSniffing ? this.reverseSniffing.toJson() : {};
|
const reverseSniffing = this.reverseSniffing ? this.reverseSniffing.toJson() : {};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export class AllSetting {
|
|||||||
this.webKeyFile = "";
|
this.webKeyFile = "";
|
||||||
this.webBasePath = "/";
|
this.webBasePath = "/";
|
||||||
this.sessionMaxAge = 360;
|
this.sessionMaxAge = 360;
|
||||||
|
this.trustedProxyCIDRs = "127.0.0.1/32,::1/128";
|
||||||
this.pageSize = 25;
|
this.pageSize = 25;
|
||||||
this.expireDiff = 0;
|
this.expireDiff = 0;
|
||||||
this.trafficDiff = 0;
|
this.trafficDiff = 0;
|
||||||
@@ -56,6 +57,7 @@ export class AllSetting {
|
|||||||
this.subUpdates = 12;
|
this.subUpdates = 12;
|
||||||
this.subEncrypt = true;
|
this.subEncrypt = true;
|
||||||
this.subShowInfo = true;
|
this.subShowInfo = true;
|
||||||
|
this.subEmailInRemark = true;
|
||||||
this.subURI = "";
|
this.subURI = "";
|
||||||
this.subJsonURI = "";
|
this.subJsonURI = "";
|
||||||
this.subClashURI = "";
|
this.subClashURI = "";
|
||||||
@@ -87,6 +89,12 @@ export class AllSetting {
|
|||||||
this.ldapDefaultTotalGB = 0;
|
this.ldapDefaultTotalGB = 0;
|
||||||
this.ldapDefaultExpiryDays = 0;
|
this.ldapDefaultExpiryDays = 0;
|
||||||
this.ldapDefaultLimitIP = 0;
|
this.ldapDefaultLimitIP = 0;
|
||||||
|
this.hasTgBotToken = false;
|
||||||
|
this.hasTwoFactorToken = false;
|
||||||
|
this.hasLdapPassword = false;
|
||||||
|
this.hasApiToken = false;
|
||||||
|
this.hasWarpSecret = false;
|
||||||
|
this.hasNordSecret = false;
|
||||||
|
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,561 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import {
|
||||||
|
KeyOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
ExpandOutlined,
|
||||||
|
CompressOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
ClusterOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
WifiOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
NodeIndexOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||||
|
import AppSidebar from '@/components/AppSidebar.vue';
|
||||||
|
import { sections as allSections } from './endpoints.js';
|
||||||
|
import EndpointSection from './EndpointSection.vue';
|
||||||
|
import CodeBlock from './CodeBlock.vue';
|
||||||
|
|
||||||
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
|
const requestUri = window.location.pathname;
|
||||||
|
const settingsHref = `${basePath}panel/settings#security`;
|
||||||
|
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const collapsedSections = ref(new Set());
|
||||||
|
const activeSection = ref('');
|
||||||
|
|
||||||
|
const sectionIcons = {
|
||||||
|
authentication: SafetyCertificateOutlined,
|
||||||
|
inbounds: NodeIndexOutlined,
|
||||||
|
server: CloudServerOutlined,
|
||||||
|
nodes: ClusterOutlined,
|
||||||
|
'custom-geo': GlobalOutlined,
|
||||||
|
backup: SaveOutlined,
|
||||||
|
settings: SettingOutlined,
|
||||||
|
'api-tokens': KeyOutlined,
|
||||||
|
'xray-settings': WifiOutlined,
|
||||||
|
subscription: LinkOutlined,
|
||||||
|
websocket: ApiOutlined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const curlExample = `curl -X GET \\
|
||||||
|
-H "Authorization: Bearer YOUR_API_TOKEN" \\
|
||||||
|
-H "Accept: application/json" \\
|
||||||
|
https://your-panel.example.com/panel/api/inbounds/list`;
|
||||||
|
|
||||||
|
const sections = computed(() => {
|
||||||
|
const q = searchQuery.value.toLowerCase().trim();
|
||||||
|
if (!q) return allSections;
|
||||||
|
return allSections
|
||||||
|
.map(s => {
|
||||||
|
const matching = s.endpoints.filter(e =>
|
||||||
|
e.path.toLowerCase().includes(q) ||
|
||||||
|
e.summary?.toLowerCase().includes(q) ||
|
||||||
|
e.method.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
return { ...s, endpoints: matching };
|
||||||
|
})
|
||||||
|
.filter(s => s.endpoints.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const endpointCount = computed(() =>
|
||||||
|
allSections.reduce((sum, s) => sum + s.endpoints.length, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleEndpoints = computed(() =>
|
||||||
|
sections.value.reduce((sum, s) => sum + s.endpoints.length, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
function isCollapsed(id) {
|
||||||
|
return collapsedSections.value.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSection(id) {
|
||||||
|
const s = new Set(collapsedSections.value);
|
||||||
|
if (s.has(id)) s.delete(id); else s.add(id);
|
||||||
|
collapsedSections.value = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAll() {
|
||||||
|
collapsedSections.value = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseAll() {
|
||||||
|
collapsedSections.value = new Set(allSections.map(s => s.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToSection(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
if (window.location.hash !== `#${id}`) {
|
||||||
|
history.replaceState(null, '', `#${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToHash() {
|
||||||
|
const id = window.location.hash.slice(1);
|
||||||
|
if (!id) return;
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let scrollObserver = null;
|
||||||
|
function onScroll() {
|
||||||
|
const toc = document.querySelector('.toc-nav');
|
||||||
|
const tocHeight = toc ? toc.offsetHeight : 56;
|
||||||
|
let current = '';
|
||||||
|
for (const s of sections.value) {
|
||||||
|
const el = document.getElementById(s.id);
|
||||||
|
if (!el) continue;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.top <= tocHeight + 20) {
|
||||||
|
current = s.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeSection.value = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
scrollObserver = onScroll;
|
||||||
|
window.addEventListener('scroll', scrollObserver, { passive: true });
|
||||||
|
window.addEventListener('hashchange', scrollToHash);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollToHash();
|
||||||
|
onScroll();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (scrollObserver) {
|
||||||
|
window.removeEventListener('scroll', scrollObserver);
|
||||||
|
}
|
||||||
|
window.removeEventListener('hashchange', scrollToHash);
|
||||||
|
});
|
||||||
|
</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 Tokens</span>
|
||||||
|
</div>
|
||||||
|
<a-button type="primary" size="small" :href="settingsHref">
|
||||||
|
Manage tokens
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<p class="token-hint">
|
||||||
|
Create, enable, or revoke named Bearer tokens in
|
||||||
|
<a :href="settingsHref">Settings → Security</a>. Send each request as
|
||||||
|
<code>Authorization: Bearer <token></code>. Token-authenticated callers skip CSRF and don't
|
||||||
|
need a session cookie. Deleting a token revokes it immediately — running bots will need a new one.
|
||||||
|
</p>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card class="curl-card" size="small" title="Quick example">
|
||||||
|
<CodeBlock :code="curlExample" lang="text" />
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="searchQuery"
|
||||||
|
placeholder="Search endpoints by path, method, or description…"
|
||||||
|
allow-clear
|
||||||
|
class="search-bar"
|
||||||
|
>
|
||||||
|
<template #prefix><SearchOutlined /></template>
|
||||||
|
</a-input-search>
|
||||||
|
<span class="match-count" v-if="searchQuery">
|
||||||
|
{{ visibleEndpoints }} / {{ endpointCount }} endpoints
|
||||||
|
</span>
|
||||||
|
<a-space size="small">
|
||||||
|
<a-button size="small" @click="expandAll">
|
||||||
|
<template #icon><ExpandOutlined /></template>
|
||||||
|
Expand all
|
||||||
|
</a-button>
|
||||||
|
<a-button size="small" @click="collapseAll">
|
||||||
|
<template #icon><CompressOutlined /></template>
|
||||||
|
Collapse all
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="toc-nav">
|
||||||
|
<span class="toc-label">On this page:</span>
|
||||||
|
<div class="toc-links">
|
||||||
|
<a
|
||||||
|
v-for="s in sections"
|
||||||
|
:key="s.id"
|
||||||
|
class="toc-link"
|
||||||
|
:class="{ active: activeSection === s.id }"
|
||||||
|
:href="`#${s.id}`"
|
||||||
|
@click.prevent="scrollToSection(s.id)"
|
||||||
|
>
|
||||||
|
<component :is="sectionIcons[s.id]" class="toc-icon" />
|
||||||
|
<span class="toc-text">{{ s.title }}</span>
|
||||||
|
<span class="toc-badge">{{ s.endpoints.length }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<EndpointSection
|
||||||
|
v-for="s in sections"
|
||||||
|
:key="s.id"
|
||||||
|
:section="s"
|
||||||
|
:icon="sectionIcons[s.id]"
|
||||||
|
:collapsed="isCollapsed(s.id)"
|
||||||
|
@toggle="toggleSection(s.id)"
|
||||||
|
/>
|
||||||
|
</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: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-lead {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
line-height: 1.65;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-lead code,
|
||||||
|
.token-hint code {
|
||||||
|
background: rgba(128, 128, 128, 0.12);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-card,
|
||||||
|
.curl-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-card-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-hint {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: rgba(0, 0, 0, 0.55);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
padding-top: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
background: rgba(128, 128, 128, 0.06);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-link:hover {
|
||||||
|
background: rgba(22, 119, 255, 0.08);
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: rgba(22, 119, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-link.active {
|
||||||
|
background: rgba(22, 119, 255, 0.12);
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: rgba(22, 119, 255, 0.3);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-text {
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: rgba(22, 119, 255, 0.12);
|
||||||
|
color: #1677ff;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-link.active .toc-badge {
|
||||||
|
background: #1677ff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body.dark .docs-title {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .docs-title {
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .docs-header {
|
||||||
|
background: #252526;
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .docs-header {
|
||||||
|
background: #0a0a0a;
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .docs-lead,
|
||||||
|
body.dark .token-hint {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .docs-lead,
|
||||||
|
html[data-theme='ultra-dark'] .token-hint {
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .docs-lead code,
|
||||||
|
body.dark .token-hint code {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .docs-lead code,
|
||||||
|
html[data-theme='ultra-dark'] .token-hint code {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .code-block {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .code-block {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .toc-nav {
|
||||||
|
background: #252526;
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .toc-nav {
|
||||||
|
background: #0a0a0a;
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .toc-label {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .toc-label {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .toc-link {
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .toc-link {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .toc-link:hover {
|
||||||
|
background: rgba(88, 166, 255, 0.12);
|
||||||
|
color: #58a6ff;
|
||||||
|
border-color: rgba(88, 166, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .toc-link.active {
|
||||||
|
background: rgba(88, 166, 255, 0.15);
|
||||||
|
color: #58a6ff;
|
||||||
|
border-color: rgba(88, 166, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .toc-badge {
|
||||||
|
background: rgba(88, 166, 255, 0.15);
|
||||||
|
color: #58a6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .toc-link.active .toc-badge {
|
||||||
|
background: #58a6ff;
|
||||||
|
color: #0d1117;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import { CopyOutlined, CheckOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
code: { type: String, default: '' },
|
||||||
|
lang: { type: String, default: 'json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const copied = ref(false);
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightJson(str) {
|
||||||
|
const escaped = escapeHtml(str);
|
||||||
|
return escaped.replace(
|
||||||
|
/("(?:[^"\\]|\\.)*")\s*(:)|("(?:[^"\\]|\\.)*")|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)\b|(true|false)|(null)|([{}[\]])/g,
|
||||||
|
(_m, key, colon, string, number, bool, nil) => {
|
||||||
|
if (colon) return `<span class="json-key">${key}</span>${colon}`;
|
||||||
|
if (string) return `<span class="json-string">${string}</span>`;
|
||||||
|
if (number) return `<span class="json-number">${number}</span>`;
|
||||||
|
if (bool) return `<span class="json-boolean">${bool}</span>`;
|
||||||
|
if (nil) return `<span class="json-null">${nil}</span>`;
|
||||||
|
return _m;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlighted = computed(() => {
|
||||||
|
if (props.lang === 'json') {
|
||||||
|
return highlightJson(props.code);
|
||||||
|
}
|
||||||
|
return escapeHtml(props.code);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copyCode() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.code);
|
||||||
|
copied.value = true;
|
||||||
|
message.success('Copied');
|
||||||
|
setTimeout(() => { copied.value = false; }, 2000);
|
||||||
|
} catch {
|
||||||
|
message.error('Copy failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="code-block-wrapper">
|
||||||
|
<div class="code-toolbar">
|
||||||
|
<span class="lang-badge">{{ lang.toUpperCase() }}</span>
|
||||||
|
<button class="copy-btn" :class="{ copied }" @click="copyCode" :title="copied ? 'Copied' : 'Copy'">
|
||||||
|
<CheckOutlined v-if="copied" />
|
||||||
|
<CopyOutlined v-else />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="code-block" :class="`lang-${lang}`"><code v-html="highlighted"></code></pre>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.code-block-wrapper {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(128, 128, 128, 0.06);
|
||||||
|
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: rgba(0, 0, 0, 0.4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:hover {
|
||||||
|
background: #fff;
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn.copied {
|
||||||
|
background: #52c41a;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: rgba(128, 128, 128, 0.04);
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.json-key { color: #0550ae; }
|
||||||
|
.json-string { color: #116329; }
|
||||||
|
.json-number { color: #9a6700; }
|
||||||
|
.json-boolean { color: #cf222e; }
|
||||||
|
.json-null { color: #8250df; }
|
||||||
|
|
||||||
|
body.dark .code-block-wrapper {
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .code-toolbar {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .lang-badge {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .code-block {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .json-key { color: #79c0ff; }
|
||||||
|
body.dark .json-string { color: #7ee787; }
|
||||||
|
body.dark .json-number { color: #d29922; }
|
||||||
|
body.dark .json-boolean { color: #ff7b72; }
|
||||||
|
body.dark .json-null { color: #d2a8ff; }
|
||||||
|
|
||||||
|
body.dark .copy-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .copy-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #58a6ff;
|
||||||
|
border-color: #58a6ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { methodColors, safeInlineHtml } from './endpoints.js';
|
||||||
|
import CodeBlock from './CodeBlock.vue';
|
||||||
|
|
||||||
|
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" v-html="safeInlineHtml(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>
|
||||||
|
<CodeBlock :code="endpoint.body" lang="json" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="endpoint.response" class="endpoint-block">
|
||||||
|
<div class="block-label">Response</div>
|
||||||
|
<CodeBlock :code="endpoint.response" lang="json" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="endpoint.errorResponse" class="endpoint-block">
|
||||||
|
<div class="block-label error-label">Error response</div>
|
||||||
|
<CodeBlock :code="endpoint.errorResponse" lang="json" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.endpoint-row {
|
||||||
|
padding: 14px 0;
|
||||||
|
margin: 0;
|
||||||
|
transition: background 0.15s;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
margin-left: -8px;
|
||||||
|
margin-right: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-row:hover {
|
||||||
|
background: rgba(128, 128, 128, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-row + .endpoint-row {
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-tag {
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
min-width: 56px;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-path {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13.5px;
|
||||||
|
word-break: break-all;
|
||||||
|
color: rgba(0, 0, 0, 0.8);
|
||||||
|
background: rgba(128, 128, 128, 0.06);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-summary {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-block {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-label {
|
||||||
|
color: #cf222e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .endpoint-row + .endpoint-row {
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .endpoint-path {
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .endpoint-summary {
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .block-label {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .error-label {
|
||||||
|
color: #ff7b72;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,192 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
DownOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import EndpointRow from './EndpointRow.vue';
|
||||||
|
import { safeInlineHtml } from './endpoints.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
section: { type: Object, required: true },
|
||||||
|
icon: { type: Object, default: null },
|
||||||
|
collapsed: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['toggle']);
|
||||||
|
|
||||||
|
const endpointLabel = computed(() =>
|
||||||
|
props.section.endpoints.length === 1
|
||||||
|
? '1 endpoint'
|
||||||
|
: `${props.section.endpoints.length} endpoints`
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section :id="section.id" class="api-section">
|
||||||
|
<div class="section-header" @click="emit('toggle')">
|
||||||
|
<div class="section-header-left">
|
||||||
|
<DownOutlined v-if="!collapsed" class="collapse-icon" />
|
||||||
|
<RightOutlined v-else class="collapse-icon" />
|
||||||
|
<component v-if="icon" :is="icon" class="section-icon" />
|
||||||
|
<h2 class="section-title">{{ section.title }}</h2>
|
||||||
|
</div>
|
||||||
|
<span class="endpoint-count">{{ endpointLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="section.description && !collapsed" class="section-description" v-html="safeInlineHtml(section.description)"></p>
|
||||||
|
|
||||||
|
<div v-if="section.subHeader && !collapsed" class="sub-header-block">
|
||||||
|
<div class="block-label">Response headers</div>
|
||||||
|
<a-table
|
||||||
|
:columns="[{ title: 'Header', dataIndex: 'name', key: 'name', width: 240 }, { title: 'Description', dataIndex: 'desc', key: 'desc' }]"
|
||||||
|
:data-source="section.subHeader"
|
||||||
|
:pagination="false"
|
||||||
|
size="small"
|
||||||
|
row-key="name"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, text }">
|
||||||
|
<span v-if="column.dataIndex === 'desc'" v-html="safeInlineHtml(text)"></span>
|
||||||
|
<template v-else>{{ text }}</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="!collapsed" 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.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: box-shadow 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-section:hover {
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header:hover .collapse-icon,
|
||||||
|
.section-header:hover .section-icon {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.4);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
white-space: nowrap;
|
||||||
|
background: rgba(128, 128, 128, 0.08);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
margin: 12px 0 14px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-header-block {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoints {
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoints > :first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body.dark .api-section {
|
||||||
|
background: #252526;
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .api-section:hover {
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .api-section {
|
||||||
|
background: #0a0a0a;
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='ultra-dark'] .api-section:hover {
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .section-title {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .section-icon {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .section-description {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .block-label {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .endpoint-count {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,910 @@
|
|||||||
|
export function safeInlineHtml(input) {
|
||||||
|
if (!input) return '';
|
||||||
|
const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const open = '<code>';
|
||||||
|
const close = '</code>';
|
||||||
|
let out = '';
|
||||||
|
let i = 0;
|
||||||
|
while (i < input.length) {
|
||||||
|
const oIdx = input.indexOf(open, i);
|
||||||
|
if (oIdx === -1) {
|
||||||
|
out += escape(input.slice(i));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out += escape(input.slice(i, oIdx));
|
||||||
|
const cIdx = input.indexOf(close, oIdx + open.length);
|
||||||
|
if (cIdx === -1) {
|
||||||
|
out += escape(input.slice(oIdx));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out += '<code>' + escape(input.slice(oIdx + open.length, cIdx)) + '</code>';
|
||||||
|
i = cIdx + close.length;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sections = [
|
||||||
|
{
|
||||||
|
id: 'authentication',
|
||||||
|
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}',
|
||||||
|
errorResponse:
|
||||||
|
'{\n "success": false,\n "msg": "Wrong username or password"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/logout',
|
||||||
|
summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.',
|
||||||
|
response: '{\n "success": true\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
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 forwarded headers only when the request comes from a configured trusted proxy.',
|
||||||
|
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).' },
|
||||||
|
],
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.' },
|
||||||
|
],
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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}',
|
||||||
|
errorResponse:
|
||||||
|
'{\n "success": false,\n "msg": "Port 443 is already in use"\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/resetTraffic',
|
||||||
|
summary: 'Zero out upload + download counters for a single inbound. Does not touch per-client counters.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.',
|
||||||
|
response: '{\n "success": true,\n "obj": [\n { "email": "user1", "lastOnline": 1700000000 },\n { "email": "user2", "lastOnline": 1699999000 }\n ]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
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.',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "cpu": 12.5,\n "mem": { "current": 2147483648, "total": 8589934592 },\n "swap": { "current": 0, "total": 4294967296 },\n "disk": { "current": 53687091200, "total": 268435456000 },\n "netIO": { "up": 1073741824, "down": 2147483648 },\n "xray": { "state": "running", "version": "v25.10.31" },\n "tcpCount": 42,\n "load": { "load1": 0.5, "load5": 0.3, "load15": 0.2 }\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 | netUp | netDown | online | load1 | load5 | load15.' },
|
||||||
|
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||||
|
],
|
||||||
|
response: '{\n "success": true,\n "obj": [\n { "t": 1700000000, "v": 12.5 },\n { "t": 1700000002, "v": 13.1 }\n ]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/xrayMetricsState',
|
||||||
|
summary: 'Xray runtime metrics state — whether the xray config has a `metrics` block, which expvar keys are flowing, and the current snapshot values for each. Returns an empty state when metrics are not configured.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/xrayMetricsHistory/:metric/:bucket',
|
||||||
|
summary: 'Time-series history for one Xray runtime metric over the last ~6 hours. Same {t, v} shape as /history/:metric/:bucket.',
|
||||||
|
params: [
|
||||||
|
{ name: 'metric', in: 'path', type: 'string', desc: 'xrAlloc | xrSys | xrHeapObjects | xrNumGC | xrPauseNs.' },
|
||||||
|
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/xrayObservatory',
|
||||||
|
summary: 'Latest snapshot from the Xray observatory — per-outbound latency, health status, and last-probe time. Only populated when the Xray config has an observatory configured.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/xrayObservatoryHistory/:tag/:bucket',
|
||||||
|
summary: 'Time-series of observatory probe results for one outbound tag. Same {t, v} shape as the other history endpoints.',
|
||||||
|
params: [
|
||||||
|
{ name: 'tag', in: 'path', type: 'string', desc: 'Outbound tag from the observatory config.' },
|
||||||
|
{ 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.',
|
||||||
|
response: '{\n "success": true,\n "obj": ["v25.10.31", "v25.9.15", "v25.8.1"]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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\u2019s currently running on this host.',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "log": { "loglevel": "warning" },\n "inbounds": [...],\n "outbounds": [...],\n "routing": { "rules": [...] }\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.',
|
||||||
|
response: '{\n "success": true,\n "obj": "550e8400-e29b-41d4-a716-446655440000"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/getNewX25519Cert',
|
||||||
|
summary: 'Generate a new X25519 keypair for Reality.',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "privateKey": "uN9qLfV3zH8w...",\n "publicKey": "5v8xPqR2sM7k..."\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/getNewmldsa65',
|
||||||
|
summary: 'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "privateKey": "mdsa65priv...",\n "publicKey": "mdsa65pub...",\n "seed": "random-seed..."\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/getNewmlkem768',
|
||||||
|
summary: 'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "clientKey": "mlkem768-client...",\n "serverKey": "mlkem768-server..."\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/getNewVlessEnc',
|
||||||
|
summary: 'Generate VLESS encryption auth options. Returns an auths array each with id, label, encryption, and decryption fields.',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "auths": [\n { "id": 0, "label": "Auth #0", "encryption": "aes-256-gcm", "decryption": "" }\n ]\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/stopXrayService',
|
||||||
|
summary: 'Stop the Xray binary. All proxies go offline immediately.',
|
||||||
|
errorResponse:
|
||||||
|
'{\n "success": false,\n "msg": "Xray is not running"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/restartXrayService',
|
||||||
|
summary: 'Reload Xray with the current config. Typically required after structural inbound or routing changes.',
|
||||||
|
errorResponse:
|
||||||
|
'{\n "success": false,\n "msg": "Xray config is invalid: ..."\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.',
|
||||||
|
params: [
|
||||||
|
{ name: 'fileName', in: 'body (form)', type: 'string', desc: 'Filename to update (e.g. geoip.dat, geosite.dat). Omit to update all defaults.' },
|
||||||
|
],
|
||||||
|
body: 'fileName=geoip.dat',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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\u2019s own log.',
|
||||||
|
params: [
|
||||||
|
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
|
||||||
|
],
|
||||||
|
body: '{\n "level": "info",\n "syslog": false\n}',
|
||||||
|
response: '{\n "success": true,\n "obj": "2025/01/01 12:00:00 [INFO] Server started\\n2025/01/01 12:00:01 [INFO] Xray is running"\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.' },
|
||||||
|
{ name: 'filter', in: 'body (form)', type: 'string', desc: 'Keyword filter — only lines containing this string.' },
|
||||||
|
{ name: 'showDirect', in: 'body (form)', type: 'string', desc: '"true" to include direct (freedom) traffic lines.' },
|
||||||
|
{ name: 'showBlocked', in: 'body (form)', type: 'string', desc: '"true" to include blocked (blackhole) traffic lines.' },
|
||||||
|
{ name: 'showProxy', in: 'body (form)', type: 'string', desc: '"true" to include proxy traffic lines.' },
|
||||||
|
],
|
||||||
|
body: 'filter=error&showDirect=false&showBlocked=true&showProxy=true',
|
||||||
|
response: '{\n "success": true,\n "obj": "2025/01/01 12:00:00 rejected vless proxy example.com reason: no valid user\\n2025/01/01 12:00:01 direct freedom ok"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.',
|
||||||
|
params: [
|
||||||
|
{ name: 'db', in: 'body (multipart)', type: 'file', desc: 'SQLite database file to upload.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/getNewEchCert',
|
||||||
|
summary: 'Generate a new ECH (Encrypted Client Hello) keypair and config list for the given SNI.',
|
||||||
|
params: [
|
||||||
|
{ name: 'sni', in: 'body (form)', type: 'string', desc: 'Server Name Indication to generate the ECH config for.' },
|
||||||
|
],
|
||||||
|
body: 'sni=example.com',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "echKeySet": "...",\n "echServerKeys": [...],\n "echConfigList": "..."\n }\n}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'nodes',
|
||||||
|
title: 'Nodes',
|
||||||
|
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.',
|
||||||
|
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "status": "online",\n "cpu": 23.5,\n "mem": 45.1\n }\n ]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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\u2019s connection details. Same body shape as /add.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||||
|
],
|
||||||
|
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/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.',
|
||||||
|
body: '{\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "status": "online",\n "cpu": 12.5,\n "mem": 45.2\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: 'cpu | mem.' },
|
||||||
|
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'custom-geo',
|
||||||
|
title: 'Custom Geo',
|
||||||
|
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: 'POST',
|
||||||
|
path: '/panel/api/backuptotgbot',
|
||||||
|
summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
title: 'Settings',
|
||||||
|
description:
|
||||||
|
'Panel configuration and user credentials. All endpoints live under /panel/setting and require a logged-in session or Bearer token.',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/setting/all',
|
||||||
|
summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/setting/defaultSettings',
|
||||||
|
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/setting/update',
|
||||||
|
summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
|
||||||
|
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/setting/updateUser',
|
||||||
|
summary: 'Change the panel admin username and password. Requires the current credentials for verification. The session is refreshed with the new values on success.',
|
||||||
|
params: [
|
||||||
|
{ name: 'oldUsername', in: 'body', type: 'string', desc: 'Current admin username.' },
|
||||||
|
{ name: 'oldPassword', in: 'body', type: 'string', desc: 'Current admin password.' },
|
||||||
|
{ name: 'newUsername', in: 'body', type: 'string', desc: 'Desired new username.' },
|
||||||
|
{ name: 'newPassword', in: 'body', type: 'string', desc: 'Desired new password.' },
|
||||||
|
],
|
||||||
|
body: '{\n "oldUsername": "admin",\n "oldPassword": "admin",\n "newUsername": "newadmin",\n "newPassword": "newpass"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/setting/restartPanel',
|
||||||
|
summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/setting/getDefaultJsonConfig',
|
||||||
|
summary: 'Return the built-in default Xray JSON config template that ships with this panel version.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'api-tokens',
|
||||||
|
title: 'API Tokens',
|
||||||
|
description:
|
||||||
|
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored plaintext so the SPA can show them on demand. Send one as <code>Authorization: Bearer <token></code> on any /panel/api/* request.',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/setting/apiTokens',
|
||||||
|
summary: 'List every API token, enabled or not.',
|
||||||
|
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "token": "abcdef-12345-...",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/setting/apiTokens/create',
|
||||||
|
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated.',
|
||||||
|
params: [
|
||||||
|
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
|
||||||
|
],
|
||||||
|
body: '{\n "name": "central-panel-a"\n}',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "id": 2,\n "name": "central-panel-a",\n "token": "new-token-string",\n "enabled": true,\n "createdAt": 1736000000\n }\n}',
|
||||||
|
errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/setting/apiTokens/delete/:id',
|
||||||
|
summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
|
||||||
|
],
|
||||||
|
response: '{\n "success": true\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/setting/apiTokens/setEnabled/:id',
|
||||||
|
summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
|
||||||
|
{ name: 'enabled', in: 'body', type: 'boolean', desc: 'New enabled state.' },
|
||||||
|
],
|
||||||
|
body: '{\n "enabled": false\n}',
|
||||||
|
response: '{\n "success": true\n}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'xray-settings',
|
||||||
|
title: 'Xray Settings',
|
||||||
|
description:
|
||||||
|
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/xray/',
|
||||||
|
summary: 'Return the Xray config template (JSON string), available inbound tags, client reverse tags, and the configured outbound test URL in one response.',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "xraySetting": "{...raw xray config...}",\n "inboundTags": "[\\"inbound-443\\"]",\n "clientReverseTags": "[]",\n "outboundTestUrl": "https://www.google.com/generate_204"\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/xray/getDefaultJsonConfig',
|
||||||
|
summary: 'Return the built-in default Xray config shipped with the panel (identical to /panel/setting/getDefaultJsonConfig).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/xray/getOutboundsTraffic',
|
||||||
|
summary: 'Return traffic statistics for every outbound. Each outbound shows up/down/total counters.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/xray/getXrayResult',
|
||||||
|
summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/xray/update',
|
||||||
|
summary: 'Save the Xray JSON config template and optionally the outbound test URL. Both are sent as form fields.',
|
||||||
|
params: [
|
||||||
|
{ name: 'xraySetting', in: 'body (form)', type: 'string', desc: 'Full Xray JSON config template.' },
|
||||||
|
{ name: 'outboundTestUrl', in: 'body (form)', type: 'string', desc: 'URL used for outbound reachability tests. Defaults to https://www.google.com/generate_204.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/xray/warp/:action',
|
||||||
|
summary: 'Manage Cloudflare Warp integration. The action parameter selects the operation.',
|
||||||
|
params: [
|
||||||
|
{ name: 'action', in: 'path', type: 'string', desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).' },
|
||||||
|
{ name: 'privateKey', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
|
||||||
|
{ name: 'publicKey', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
|
||||||
|
{ name: 'license', in: 'body (form)', type: 'string', desc: 'Required when action=license.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/xray/nord/:action',
|
||||||
|
summary: 'Manage NordVPN integration. The action parameter selects the operation.',
|
||||||
|
params: [
|
||||||
|
{ name: 'action', in: 'path', type: 'string', desc: 'countries — list available countries. servers — list servers in a country (sends countryId). reg — get NordVPN credentials (sends token). setKey — store NordVPN API key (sends key). data — return current NordVPN connection data. del — delete NordVPN data.' },
|
||||||
|
{ name: 'countryId', in: 'body (form)', type: 'string', desc: 'Required when action=servers.' },
|
||||||
|
{ name: 'token', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
|
||||||
|
{ name: 'key', in: 'body (form)', type: 'string', desc: 'Required when action=setKey.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/xray/resetOutboundsTraffic',
|
||||||
|
summary: 'Reset traffic counters for a specific outbound by tag.',
|
||||||
|
params: [
|
||||||
|
{ name: 'tag', in: 'body (form)', type: 'string', desc: 'Outbound tag to reset (e.g. "proxy", "direct").' },
|
||||||
|
],
|
||||||
|
body: 'tag=proxy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/xray/testOutbound',
|
||||||
|
summary: 'Test an outbound configuration. Sends the outbound JSON (required), optionally all outbounds (to resolve sockopt.dialerProxy dependencies), and a mode flag.',
|
||||||
|
params: [
|
||||||
|
{ name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound to test (required).' },
|
||||||
|
{ name: 'allOutbounds', in: 'body (form)', type: 'string', desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.' },
|
||||||
|
{ name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for a fast dial-only probe (parallel-safe). Default/empty uses a full HTTP probe through a temp xray instance.' },
|
||||||
|
],
|
||||||
|
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'subscription',
|
||||||
|
title: 'Subscription Server',
|
||||||
|
description:
|
||||||
|
'A separate HTTP/HTTPS server that serves proxy subscription links (standard, JSON, and Clash) to clients. The server listens on its own port (default 10882) and is configured in Settings → Subscription. Paths are configurable; defaults are shown below. All subscription endpoints set response headers for client apps to read traffic/expiry info.',
|
||||||
|
subHeader: [
|
||||||
|
{ name: 'Subscription-Userinfo', desc: 'Traffic and expiry: <code>upload=N; download=N; total=N; expire=TS</code>' },
|
||||||
|
{ name: 'Profile-Title', desc: 'Base64-encoded subscription display name' },
|
||||||
|
{ name: 'Profile-Web-Page-Url', desc: 'Link to the subscription info page' },
|
||||||
|
{ name: 'Support-Url', desc: 'Support contact URL configured in settings' },
|
||||||
|
{ name: 'Profile-Update-Interval', desc: 'Suggested polling interval in minutes (e.g. <code>10</code>)' },
|
||||||
|
{ name: 'Announce', desc: 'Base64-encoded announcement string' },
|
||||||
|
{ name: 'Routing-Enable', desc: '<code>true</code> or <code>false</code> — whether routing rules are included' },
|
||||||
|
{ name: 'Routing', desc: 'Global routing rules for client apps that support them (e.g. Happ)' },
|
||||||
|
],
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/{subPath}:subid',
|
||||||
|
summary: 'Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. Default path: /sub/:subid.',
|
||||||
|
params: [
|
||||||
|
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/{jsonPath}:subid',
|
||||||
|
summary: 'Return subscription as a JSON array of proxy configs (one per enabled client). Only when JSON subscription is enabled in settings. Default path: /json/:subid.',
|
||||||
|
params: [
|
||||||
|
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/{clashPath}:subid',
|
||||||
|
summary: 'Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
|
||||||
|
params: [
|
||||||
|
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'websocket',
|
||||||
|
title: 'WebSocket',
|
||||||
|
description:
|
||||||
|
'Real-time status updates via WebSocket. Connect once at <code>ws://<panel>/ws</code> to receive a stream of JSON messages without polling. Requires an authenticated session cookie (Bearer token auth is not supported). Each message has a <code>type</code> field that identifies the payload shape.',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/ws',
|
||||||
|
summary: 'Upgrade an HTTP connection to a WebSocket. Requires an authenticated session cookie (Bearer token auth is not supported here). Returns 101 Switching Protocols on success. The server then pushes JSON messages described below.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'WS',
|
||||||
|
path: '→ type: status',
|
||||||
|
summary: 'Server health snapshot pushed every 2 seconds. Contains CPU, memory, swap, disk, network IO, load, and Xray state — same shape as <code>GET /panel/api/server/status</code>.',
|
||||||
|
response: '{\n "type": "status",\n "data": { "cpu": 12.5, "mem": { "current": 2147483648, "total": 8589934592 }, "xray": { "state": "running" } }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'WS',
|
||||||
|
path: '→ type: xrayState',
|
||||||
|
summary: 'Xray process state change. Fired when Xray starts, stops, or encounters an error.',
|
||||||
|
response: '{\n "type": "xrayState",\n "data": "running"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'WS',
|
||||||
|
path: '→ type: notification',
|
||||||
|
summary: 'In-panel toast notification. Fired on Xray stop/restart, DB import, panel restart, etc.',
|
||||||
|
response: '{\n "type": "notification",\n "title": "Xray service restarted",\n "body": "Xray has been restarted successfully",\n "severity": "success"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'WS',
|
||||||
|
path: '→ type: invalidate',
|
||||||
|
summary: 'Instructs the UI to re-fetch a resource. Fired when another admin session modifies data (e.g. toggling inbound enable).',
|
||||||
|
response: '{\n "type": "invalidate",\n "resource": "inbounds"\n}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const methodColors = {
|
||||||
|
GET: 'blue',
|
||||||
|
POST: 'green',
|
||||||
|
PUT: 'orange',
|
||||||
|
PATCH: 'orange',
|
||||||
|
DELETE: 'red',
|
||||||
|
WS: 'purple',
|
||||||
|
};
|
||||||
@@ -53,6 +53,7 @@ const form = reactive({
|
|||||||
flow: '',
|
flow: '',
|
||||||
subId: '',
|
subId: '',
|
||||||
tgId: 0,
|
tgId: 0,
|
||||||
|
comment: '',
|
||||||
limitIp: 0,
|
limitIp: 0,
|
||||||
totalGB: 0,
|
totalGB: 0,
|
||||||
expiryTime: 0, // ms epoch; negative => delayed start days
|
expiryTime: 0, // ms epoch; negative => delayed start days
|
||||||
@@ -85,6 +86,7 @@ watch(() => props.open, (next) => {
|
|||||||
form.flow = '';
|
form.flow = '';
|
||||||
form.subId = '';
|
form.subId = '';
|
||||||
form.tgId = 0;
|
form.tgId = 0;
|
||||||
|
form.comment = '';
|
||||||
form.limitIp = 0;
|
form.limitIp = 0;
|
||||||
form.totalGB = 0;
|
form.totalGB = 0;
|
||||||
form.expiryTime = 0;
|
form.expiryTime = 0;
|
||||||
@@ -135,6 +137,7 @@ function buildClients() {
|
|||||||
|
|
||||||
if (form.subId.length > 0) c.subId = form.subId;
|
if (form.subId.length > 0) c.subId = form.subId;
|
||||||
c.tgId = form.tgId;
|
c.tgId = form.tgId;
|
||||||
|
if (form.comment.length > 0) c.comment = form.comment;
|
||||||
c.security = form.security;
|
c.security = form.security;
|
||||||
c.limitIp = form.limitIp;
|
c.limitIp = form.limitIp;
|
||||||
// Use the clien's totalGB setter (ms epoch and bytes already handled
|
// Use the clien's totalGB setter (ms epoch and bytes already handled
|
||||||
@@ -227,6 +230,10 @@ async function submit() {
|
|||||||
<a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
|
<a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
|
||||||
</a-form-item>
|
</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-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
|
||||||
<a-input-number v-model:value="form.limitIp" :min="0" />
|
<a-input-number v-model:value="form.limitIp" :min="0" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
import {
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
@@ -31,6 +31,9 @@ const props = defineProps({
|
|||||||
onlineClients: { type: Array, default: () => [] },
|
onlineClients: { type: Array, default: () => [] },
|
||||||
lastOnlineMap: { type: Object, default: () => ({}) },
|
lastOnlineMap: { type: Object, default: () => ({}) },
|
||||||
isDarkTheme: { type: Boolean, default: false },
|
isDarkTheme: { type: Boolean, default: false },
|
||||||
|
pageSize: { type: Number, default: 0 },
|
||||||
|
totalClientCount: { type: Number, default: 0 },
|
||||||
|
statsVersion: { type: Number, default: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -39,14 +42,33 @@ const emit = defineEmits([
|
|||||||
'info-client',
|
'info-client',
|
||||||
'reset-traffic-client',
|
'reset-traffic-client',
|
||||||
'delete-client',
|
'delete-client',
|
||||||
|
'delete-clients',
|
||||||
'toggle-enable-client',
|
'toggle-enable-client',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const inbound = computed(() => props.dbInbound.toInbound());
|
const inbound = computed(() => props.dbInbound.toInbound());
|
||||||
const clients = computed(() => inbound.value?.clients || []);
|
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 =======================================
|
// === Per-client stats lookup =======================================
|
||||||
|
// statsVersion bumps on every ws merge so this computed re-evaluates
|
||||||
|
// (DBInbound isn't reactive — the in-place stat mutations alone don't
|
||||||
|
// trigger Vue's tracking).
|
||||||
const statsMap = computed(() => {
|
const statsMap = computed(() => {
|
||||||
|
void props.statsVersion;
|
||||||
const m = new Map();
|
const m = new Map();
|
||||||
for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
|
for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
|
||||||
return m;
|
return m;
|
||||||
@@ -122,7 +144,7 @@ function statsExpColor(email) {
|
|||||||
return PURPLE;
|
return PURPLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRemovable = computed(() => clients.value.length > 1);
|
const isRemovable = computed(() => (props.totalClientCount || clients.value.length) > 1);
|
||||||
|
|
||||||
function totalGbDisplay(client) {
|
function totalGbDisplay(client) {
|
||||||
if (!client.totalGB || client.totalGB <= 0) return '';
|
if (!client.totalGB || client.totalGB <= 0) return '';
|
||||||
@@ -162,23 +184,119 @@ function confirmDelete(client) {
|
|||||||
function rowKey(client) {
|
function rowKey(client) {
|
||||||
return client.email || client.id || client.password || JSON.stringify(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;
|
||||||
|
});
|
||||||
|
|
||||||
|
const statsClient = ref(null);
|
||||||
|
function openStats(client) {
|
||||||
|
statsClient.value = client;
|
||||||
|
}
|
||||||
|
function closeStats() {
|
||||||
|
statsClient.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="client-list" :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme }">
|
<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 ===================== -->
|
<!-- ====================== Desktop: grid table ===================== -->
|
||||||
<template v-if="!isMobile">
|
<template v-if="!isMobile">
|
||||||
<div class="client-row client-list-header">
|
<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-actions">{{ t('pages.settings.actions') }}</div>
|
||||||
<div class="cell cell-enable">{{ t('enable') }}</div>
|
<div class="cell cell-enable">{{ t('enable') }}</div>
|
||||||
<div class="cell cell-online">{{ t('online') }}</div>
|
<div class="cell cell-online">{{ t('online') }}</div>
|
||||||
<div class="cell cell-client">{{ t('pages.inbounds.client') }}</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-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-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
|
||||||
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
|
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="client in clients" :key="rowKey(client)" class="client-row">
|
<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">
|
<div class="cell cell-actions">
|
||||||
<a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
|
<a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
|
||||||
<QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
|
<QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
|
||||||
@@ -262,6 +380,15 @@ function rowKey(client) {
|
|||||||
</a-popover>
|
</a-popover>
|
||||||
</div>
|
</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">
|
<div class="cell cell-alltime">
|
||||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,8 +428,11 @@ function rowKey(client) {
|
|||||||
|
|
||||||
<!-- ====================== Mobile: card list ======================= -->
|
<!-- ====================== Mobile: card list ======================= -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-for="client in clients" :key="rowKey(client)" class="client-card">
|
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-card"
|
||||||
|
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
||||||
<div class="client-card-head">
|
<div class="client-card-head">
|
||||||
|
<a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
|
||||||
|
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template #title>
|
<template #title>
|
||||||
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
||||||
@@ -316,6 +446,9 @@ function rowKey(client) {
|
|||||||
<span class="client-email">{{ client.email }}</span>
|
<span class="client-email">{{ client.email }}</span>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<div class="client-card-actions">
|
<div class="client-card-actions">
|
||||||
|
<a-tooltip :title="t('info')">
|
||||||
|
<InfoCircleOutlined class="row-icon" @click="openStats(client)" />
|
||||||
|
</a-tooltip>
|
||||||
<a-switch :checked="client.enable" size="small"
|
<a-switch :checked="client.enable" size="small"
|
||||||
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
||||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||||
@@ -342,44 +475,60 @@ function rowKey(client) {
|
|||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="client.comment && client.comment.trim()" class="client-comment-line">
|
<a-modal :open="!!statsClient" :footer="null" :width="360" centered
|
||||||
{{ client.comment.length > 80 ? client.comment.substring(0, 77) + '…' : client.comment }}
|
:title="statsClient ? statsClient.email || t('info') : ''" @cancel="closeStats">
|
||||||
</div>
|
<div v-if="statsClient" class="client-card-foot">
|
||||||
|
<div v-if="statsClient.comment && statsClient.comment.trim()" class="client-comment-line">
|
||||||
<div class="client-card-foot">
|
{{ statsClient.comment }}
|
||||||
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
||||||
<a-tag :color="clientStatsColor(client.email)">
|
<a-tag :color="clientStatsColor(statsClient.email)">
|
||||||
{{ SizeFormatter.sizeFormat(getSum(client.email)) }} /
|
{{ SizeFormatter.sizeFormat(getSum(statsClient.email)) }} /
|
||||||
<InfinityIcon v-if="isUnlimitedTotal(client)" />
|
<InfinityIcon v-if="isUnlimitedTotal(statsClient)" />
|
||||||
<template v-else>{{ totalGbDisplay(client) }}</template>
|
<template v-else>{{ totalGbDisplay(statsClient) }}</template>
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('remained') }}</span>
|
||||||
|
<a-tag v-if="isUnlimitedTotal(statsClient)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
||||||
|
<InfinityIcon />
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else :color="isClientDepleted(statsClient.email) ? 'red' : ''">
|
||||||
|
{{ SizeFormatter.sizeFormat(getRem(statsClient.email)) }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
||||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(statsClient.email)) }}</a-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">{{ t('online') }}</span>
|
<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-if="statsClient.enable && isClientOnline(statsClient.email)" color="green">{{ t('online') }}</a-tag>
|
||||||
<a-tag v-else>{{ t('offline') }}</a-tag>
|
<a-tag v-else>{{ t('offline') }}</a-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
|
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
|
||||||
<a-tag v-if="client.expiryTime > 0" :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
|
<a-tag v-if="statsClient.expiryTime > 0"
|
||||||
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
|
:color="ColorUtils.userExpiryColor(expireDiff, statsClient, isDarkTheme)">
|
||||||
|
{{ IntlUtil.formatRelativeTime(statsClient.expiryTime) }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<a-tag v-else-if="client.expiryTime < 0" color="green">
|
<a-tag v-else-if="statsClient.expiryTime < 0" color="green">
|
||||||
{{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
|
{{ -statsClient.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<a-tag v-else color="purple">
|
<a-tag v-else color="purple">
|
||||||
<InfinityIcon />
|
<InfinityIcon />
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a-modal>
|
||||||
</template>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -389,8 +538,28 @@ function rowKey(client) {
|
|||||||
font-size: 13px;
|
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 {
|
.client-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
/* Default — no select column (single-client inbounds). The .has-select
|
||||||
|
* modifier below prepends the 40px checkbox column. */
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
140px
|
140px
|
||||||
/* actions */
|
/* actions */
|
||||||
@@ -404,6 +573,8 @@ function rowKey(client) {
|
|||||||
/* traffic */
|
/* traffic */
|
||||||
130px
|
130px
|
||||||
/* all-time */
|
/* all-time */
|
||||||
|
130px
|
||||||
|
/* remained */
|
||||||
140px;
|
140px;
|
||||||
/* expiry */
|
/* expiry */
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -412,6 +583,28 @@ function rowKey(client) {
|
|||||||
border-top: 1px solid rgba(128, 128, 128, 0.12);
|
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 {
|
.client-row:last-child {
|
||||||
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
||||||
}
|
}
|
||||||
@@ -432,10 +625,12 @@ function rowKey(client) {
|
|||||||
/* allow grid children to shrink instead of overflowing */
|
/* allow grid children to shrink instead of overflowing */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell-select,
|
||||||
.cell-actions,
|
.cell-actions,
|
||||||
.cell-enable,
|
.cell-enable,
|
||||||
.cell-online,
|
.cell-online,
|
||||||
.cell-alltime {
|
.cell-alltime,
|
||||||
|
.cell-remained {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -547,6 +742,12 @@ function rowKey(client) {
|
|||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-list-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 16px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Mobile card list =========================================== */
|
/* ===== Mobile card list =========================================== */
|
||||||
.client-list.is-mobile {
|
.client-list.is-mobile {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { HttpUtil, SizeFormatter, IntlUtil } from '@/utils';
|
||||||
|
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
dbInbound: { type: Object, default: null },
|
||||||
|
dbInbounds: { type: Array, default: () => [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'saved']);
|
||||||
|
|
||||||
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
|
|
||||||
|
const sourceInboundId = ref(null);
|
||||||
|
const selectedEmails = ref([]);
|
||||||
|
const flow = ref('');
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const sources = computed(() => {
|
||||||
|
if (!props.dbInbound) return [];
|
||||||
|
return props.dbInbounds
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
row.id !== props.dbInbound.id &&
|
||||||
|
typeof row.isMultiUser === 'function' &&
|
||||||
|
row.isMultiUser(),
|
||||||
|
)
|
||||||
|
.map((row) => {
|
||||||
|
let count = 0;
|
||||||
|
try { count = (row.toInbound().clients || []).length; } catch (_e) { /* ignore */ }
|
||||||
|
return { id: row.id, label: `${row.remark || `#${row.id}`} (${row.protocol}, ${count})` };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceInbound = computed(() => {
|
||||||
|
if (!sourceInboundId.value) return null;
|
||||||
|
return props.dbInbounds.find((r) => r.id === sourceInboundId.value) || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceClients = computed(() => {
|
||||||
|
const sb = sourceInbound.value;
|
||||||
|
if (!sb) return [];
|
||||||
|
let list = [];
|
||||||
|
try { list = sb.toInbound().clients || []; } catch (_e) { /* ignore */ }
|
||||||
|
const stats = new Map((sb.clientStats || []).map((s) => [s.email, s]));
|
||||||
|
return list
|
||||||
|
.filter((c) => c.email)
|
||||||
|
.map((c) => {
|
||||||
|
const s = stats.get(c.email);
|
||||||
|
const used = s ? (s.up || 0) + (s.down || 0) : 0;
|
||||||
|
let expiryLabel = t('unlimited');
|
||||||
|
if (c.expiryTime > 0) expiryLabel = IntlUtil.formatDate(c.expiryTime);
|
||||||
|
else if (c.expiryTime < 0) expiryLabel = `${-c.expiryTime / 86400000}d`;
|
||||||
|
return { email: c.email, trafficLabel: SizeFormatter.sizeFormat(used), expiryLabel };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const showFlow = computed(() => {
|
||||||
|
if (!props.dbInbound) return false;
|
||||||
|
try {
|
||||||
|
const inb = props.dbInbound.toInbound();
|
||||||
|
return !!(inb && typeof inb.canEnableTlsFlow === 'function' && inb.canEnableTlsFlow());
|
||||||
|
} catch (_e) { return false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = computed(() => [
|
||||||
|
{ title: t('pages.inbounds.email'), dataIndex: 'email', width: 280 },
|
||||||
|
{ title: t('pages.inbounds.traffic'), dataIndex: 'trafficLabel', width: 140 },
|
||||||
|
{ title: t('pages.inbounds.expireDate'), dataIndex: 'expiryLabel', width: 160 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rowSelection = computed(() => ({
|
||||||
|
selectedRowKeys: selectedEmails.value,
|
||||||
|
onChange: (keys) => { selectedEmails.value = keys; },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (!props.dbInbound) return t('pages.client.copyFromInbound');
|
||||||
|
const target = props.dbInbound.remark || `#${props.dbInbound.id}`;
|
||||||
|
return `${t('pages.client.copyToInbound')} ${target}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (!next) return;
|
||||||
|
sourceInboundId.value = null;
|
||||||
|
selectedEmails.value = [];
|
||||||
|
flow.value = '';
|
||||||
|
saving.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(sourceInboundId, () => {
|
||||||
|
selectedEmails.value = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectAll() {
|
||||||
|
selectedEmails.value = sourceClients.value.map((c) => c.email);
|
||||||
|
}
|
||||||
|
function clearAll() {
|
||||||
|
selectedEmails.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ok() {
|
||||||
|
if (!sourceInboundId.value) {
|
||||||
|
message.error(t('pages.client.copySelectSourceFirst'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!props.dbInbound) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
sourceInboundId: sourceInboundId.value,
|
||||||
|
clientEmails: selectedEmails.value,
|
||||||
|
};
|
||||||
|
if (showFlow.value && flow.value) payload.flow = flow.value;
|
||||||
|
const msg = await HttpUtil.post(
|
||||||
|
`/panel/api/inbounds/${props.dbInbound.id}/copyClients`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
if (!msg?.success) return;
|
||||||
|
const obj = msg.obj || {};
|
||||||
|
const addedCount = (obj.added || []).length;
|
||||||
|
const errorList = obj.errors || [];
|
||||||
|
if (addedCount > 0) {
|
||||||
|
message.success(`${t('pages.client.copyResultSuccess')}: ${addedCount}`);
|
||||||
|
} else {
|
||||||
|
message.warning(t('pages.client.copyResultNone'));
|
||||||
|
}
|
||||||
|
if (errorList.length > 0) {
|
||||||
|
message.error(`${t('pages.client.copyResultErrors')}: ${errorList.join('; ')}`);
|
||||||
|
}
|
||||||
|
emit('saved');
|
||||||
|
emit('update:open', false);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (saving.value) return;
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="title" :ok-text="t('pages.client.copySelected')" :cancel-text="t('close')"
|
||||||
|
:confirm-loading="saving" :mask-closable="false" width="720px" @ok="ok" @cancel="close">
|
||||||
|
<a-space direction="vertical" :style="{ width: '100%' }">
|
||||||
|
<div>
|
||||||
|
<div :style="{ marginBottom: '6px' }">{{ t('pages.client.copySource') }}</div>
|
||||||
|
<a-select v-model:value="sourceInboundId" :style="{ width: '100%' }" allow-clear>
|
||||||
|
<a-select-option v-for="item in sources" :key="item.id" :value="item.id">
|
||||||
|
{{ item.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="sourceInboundId">
|
||||||
|
<a-space :style="{ marginBottom: '8px' }">
|
||||||
|
<a-button size="small" @click="selectAll">{{ t('pages.client.selectAll') }}</a-button>
|
||||||
|
<a-button size="small" @click="clearAll">{{ t('pages.client.clearAll') }}</a-button>
|
||||||
|
</a-space>
|
||||||
|
<a-table :columns="columns" :data-source="sourceClients" :pagination="false" size="small"
|
||||||
|
:row-key="(r) => r.email" :row-selection="rowSelection" :scroll="{ y: 280 }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showFlow">
|
||||||
|
<div :style="{ marginBottom: '6px' }">{{ t('pages.client.copyFlowLabel') }}</div>
|
||||||
|
<a-select v-model:value="flow" :style="{ width: '100%' }" allow-clear>
|
||||||
|
<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>
|
||||||
|
<div :style="{ marginTop: '4px', fontSize: '12px', opacity: 0.7 }">
|
||||||
|
{{ t('pages.client.copyFlowHint') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-space>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
SizeFormatter,
|
SizeFormatter,
|
||||||
Wireguard,
|
Wireguard,
|
||||||
} from '@/utils';
|
} from '@/utils';
|
||||||
|
import { getRandomRealityTarget } from '@/models/reality-targets';
|
||||||
import {
|
import {
|
||||||
Inbound,
|
Inbound,
|
||||||
Protocols,
|
Protocols,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
import { DBInbound } from '@/models/dbinbound.js';
|
import { DBInbound } from '@/models/dbinbound.js';
|
||||||
import FinalMaskForm from '@/components/FinalMaskForm.vue';
|
import FinalMaskForm from '@/components/FinalMaskForm.vue';
|
||||||
import DateTimePicker from '@/components/DateTimePicker.vue';
|
import DateTimePicker from '@/components/DateTimePicker.vue';
|
||||||
|
import JsonEditor from '@/components/JsonEditor.vue';
|
||||||
import { useNodeList } from '@/composables/useNodeList.js';
|
import { useNodeList } from '@/composables/useNodeList.js';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -69,6 +71,7 @@ const inbound = ref(null);
|
|||||||
const dbForm = ref(null);
|
const dbForm = ref(null);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const advancedJson = ref({ stream: '', sniffing: '', settings: '' });
|
const advancedJson = ref({ stream: '', sniffing: '', settings: '' });
|
||||||
|
const activeTabKey = ref('basic');
|
||||||
// Cached default cert/key paths from /panel/setting/defaultSettings —
|
// Cached default cert/key paths from /panel/setting/defaultSettings —
|
||||||
// powers the "Set default cert" button on the TLS form.
|
// powers the "Set default cert" button on the TLS form.
|
||||||
const defaultCert = ref('');
|
const defaultCert = ref('');
|
||||||
@@ -240,9 +243,60 @@ watch(() => props.open, (next) => {
|
|||||||
dbForm.value = freshDbForm();
|
dbForm.value = freshDbForm();
|
||||||
primeAdvancedJson();
|
primeAdvancedJson();
|
||||||
}
|
}
|
||||||
|
activeTabKey.value = 'basic';
|
||||||
fetchDefaultCertSettings();
|
fetchDefaultCertSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyAdvancedJsonToBasic() {
|
||||||
|
if (!inbound.value) return true;
|
||||||
|
let parsedSettings;
|
||||||
|
let parsedStream;
|
||||||
|
let parsedSniffing;
|
||||||
|
try {
|
||||||
|
parsedSettings = advancedJson.value.settings.trim()
|
||||||
|
? JSON.parse(advancedJson.value.settings)
|
||||||
|
: inbound.value.settings?.toJson?.();
|
||||||
|
} catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return false; }
|
||||||
|
try {
|
||||||
|
parsedStream = advancedJson.value.stream.trim()
|
||||||
|
? JSON.parse(advancedJson.value.stream)
|
||||||
|
: inbound.value.stream?.toJson?.();
|
||||||
|
} catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return false; }
|
||||||
|
try {
|
||||||
|
parsedSniffing = advancedJson.value.sniffing.trim()
|
||||||
|
? JSON.parse(advancedJson.value.sniffing)
|
||||||
|
: inbound.value.sniffing?.toJson?.();
|
||||||
|
} catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return false; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
inbound.value = Inbound.fromJson({
|
||||||
|
port: inbound.value.port,
|
||||||
|
listen: inbound.value.listen,
|
||||||
|
protocol: inbound.value.protocol,
|
||||||
|
settings: parsedSettings,
|
||||||
|
streamSettings: parsedStream,
|
||||||
|
tag: inbound.value.tag,
|
||||||
|
sniffing: parsedSniffing,
|
||||||
|
clientStats: inbound.value.clientStats,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
message.error(`Advanced JSON: ${e.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isRevertingTab = false;
|
||||||
|
watch(activeTabKey, (next, prev) => {
|
||||||
|
if (isRevertingTab) { isRevertingTab = false; return; }
|
||||||
|
if (prev === 'advanced' && next !== 'advanced') {
|
||||||
|
if (!applyAdvancedJsonToBasic()) {
|
||||||
|
isRevertingTab = true;
|
||||||
|
activeTabKey.value = 'advanced';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// In add mode, switching protocol restamps settings + re-syncs port.
|
// In add mode, switching protocol restamps settings + re-syncs port.
|
||||||
function onProtocolChange(next) {
|
function onProtocolChange(next) {
|
||||||
if (props.mode === 'edit' || !inbound.value) return;
|
if (props.mode === 'edit' || !inbound.value) return;
|
||||||
@@ -339,11 +393,9 @@ function clearMldsa65() {
|
|||||||
inbound.value.stream.reality.settings.mldsa65Verify = '';
|
inbound.value.stream.reality.settings.mldsa65Verify = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reality target/SNI randomizer — only available if the helper is loaded
|
|
||||||
function randomizeRealityTarget() {
|
function randomizeRealityTarget() {
|
||||||
if (!inbound.value?.stream?.reality) return;
|
if (!inbound.value?.stream?.reality) return;
|
||||||
if (typeof window.getRandomRealityTarget !== 'function') return;
|
const t = getRandomRealityTarget();
|
||||||
const t = window.getRandomRealityTarget();
|
|
||||||
inbound.value.stream.reality.target = t.target;
|
inbound.value.stream.reality.target = t.target;
|
||||||
inbound.value.stream.reality.serverNames = t.sni;
|
inbound.value.stream.reality.serverNames = t.sni;
|
||||||
}
|
}
|
||||||
@@ -393,16 +445,29 @@ async function fetchDefaultCertSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === VLESS encryption helpers =======================================
|
// === VLESS encryption helpers =======================================
|
||||||
// `xray vlessenc` returns both X25519 and ML-KEM-768 variants every
|
// `xray vlessenc` returns both X25519 and ML-KEM-768 auth variants every
|
||||||
// call; the user clicks one of two buttons to pick which block goes
|
// call; the user clicks one button to pick which block goes into
|
||||||
// into decryption/encryption.
|
// decryption/encryption. Both generated strings share the same hybrid
|
||||||
async function getNewVlessEnc(authLabel) {
|
// mlkem768x25519plus prefix; the auth choice is the final key block.
|
||||||
if (!authLabel || !inbound.value?.settings) return;
|
function normalizeVlessAuthLabel(label = '') {
|
||||||
|
return label.toLowerCase().replace(/[-_\s]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesVlessAuth(block, authId) {
|
||||||
|
if (block?.id === authId) return true;
|
||||||
|
const label = normalizeVlessAuthLabel(block?.label);
|
||||||
|
if (authId === 'mlkem768') return label.includes('mlkem768');
|
||||||
|
if (authId === 'x25519') return label.includes('x25519');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNewVlessEnc(authId) {
|
||||||
|
if (!authId || !inbound.value?.settings) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
|
const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
|
||||||
if (!msg?.success) return;
|
if (!msg?.success) return;
|
||||||
const block = (msg.obj?.auths || []).find((a) => a.label === authLabel);
|
const block = (msg.obj?.auths || []).find((a) => matchesVlessAuth(a, authId));
|
||||||
if (!block) return;
|
if (!block) return;
|
||||||
inbound.value.settings.decryption = block.decryption;
|
inbound.value.settings.decryption = block.decryption;
|
||||||
inbound.value.settings.encryption = block.encryption;
|
inbound.value.settings.encryption = block.encryption;
|
||||||
@@ -417,6 +482,17 @@ function clearVlessEnc() {
|
|||||||
inbound.value.settings.encryption = 'none';
|
inbound.value.settings.encryption = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedVlessAuth = computed(() => {
|
||||||
|
const encryption = inbound.value?.settings?.encryption;
|
||||||
|
if (!encryption || encryption === 'none') return 'None';
|
||||||
|
|
||||||
|
const parts = encryption.split('.').filter(Boolean);
|
||||||
|
const authKey = parts[parts.length - 1] || '';
|
||||||
|
if (!authKey) return 'Custom';
|
||||||
|
|
||||||
|
return authKey.length > 300 ? 'ML-KEM-768 auth' : 'X25519 auth';
|
||||||
|
});
|
||||||
|
|
||||||
// === SS method change tracks legacy semantics =========================
|
// === SS method change tracks legacy semantics =========================
|
||||||
function onSSMethodChange() {
|
function onSSMethodChange() {
|
||||||
inbound.value.settings.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method);
|
inbound.value.settings.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method);
|
||||||
@@ -549,7 +625,7 @@ watch(
|
|||||||
<template>
|
<template>
|
||||||
<a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :confirm-loading="saving"
|
<a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :confirm-loading="saving"
|
||||||
:mask-closable="false" width="780px" @ok="submit" @cancel="close">
|
:mask-closable="false" width="780px" @ok="submit" @cancel="close">
|
||||||
<a-tabs v-if="inbound && dbForm" default-active-key="basic">
|
<a-tabs v-if="inbound && dbForm" v-model:active-key="activeTabKey">
|
||||||
<!-- ============================== BASICS ============================== -->
|
<!-- ============================== BASICS ============================== -->
|
||||||
<a-tab-pane key="basic" :tab="t('pages.xray.basicTemplate')">
|
<a-tab-pane key="basic" :tab="t('pages.xray.basicTemplate')">
|
||||||
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
||||||
@@ -559,7 +635,7 @@ watch(
|
|||||||
<a-form-item :label="t('pages.inbounds.remark')">
|
<a-form-item :label="t('pages.inbounds.remark')">
|
||||||
<a-input v-model:value="dbForm.remark" />
|
<a-input v-model:value="dbForm.remark" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item :label="t('pages.inbounds.deployTo')">
|
<a-form-item v-if="selectableNodes.length > 0" :label="t('pages.inbounds.deployTo')">
|
||||||
<a-select v-model:value="dbForm.nodeId" :disabled="mode === 'edit'"
|
<a-select v-model:value="dbForm.nodeId" :disabled="mode === 'edit'"
|
||||||
:placeholder="t('pages.inbounds.localPanel')" allow-clear>
|
:placeholder="t('pages.inbounds.localPanel')" allow-clear>
|
||||||
<a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option>
|
<a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option>
|
||||||
@@ -604,10 +680,7 @@ watch(
|
|||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
||||||
<!-- ============================== PROTOCOL ============================== -->
|
<!-- ============================== PROTOCOL ============================== -->
|
||||||
<!-- TUN has no per-protocol form yet (interface/mtu/gateway live in
|
<a-tab-pane key="protocol" :tab="t('pages.inbounds.protocol')">
|
||||||
settings JSON), so the tab would render empty — hide it until
|
|
||||||
a TUN form is added. -->
|
|
||||||
<a-tab-pane v-if="protocol !== Protocols.TUN" key="protocol" :tab="t('pages.inbounds.protocol')">
|
|
||||||
<!-- Multi-user inbounds: in add mode embed the first client form,
|
<!-- Multi-user inbounds: in add mode embed the first client form,
|
||||||
in edit mode show a count summary. -->
|
in edit mode show a count summary. -->
|
||||||
<template v-if="isMultiUser">
|
<template v-if="isMultiUser">
|
||||||
@@ -731,14 +804,17 @@ watch(
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label=" ">
|
<a-form-item label=" ">
|
||||||
<a-space :size="8" wrap>
|
<a-space :size="8" wrap>
|
||||||
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('X25519, not Post-Quantum')">
|
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('x25519')">
|
||||||
X25519
|
X25519 auth
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('ML-KEM-768, Post-Quantum')">
|
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('mlkem768')">
|
||||||
ML-KEM-768
|
ML-KEM-768 auth
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button danger @click="clearVlessEnc">Clear</a-button>
|
<a-button danger @click="clearVlessEnc">Clear</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
|
<a-typography-text type="secondary" class="vless-auth-state">
|
||||||
|
Selected: {{ selectedVlessAuth }}
|
||||||
|
</a-typography-text>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
|
|
||||||
@@ -817,24 +893,126 @@ watch(
|
|||||||
<!-- Tunnel -->
|
<!-- Tunnel -->
|
||||||
<a-form v-if="protocol === Protocols.TUNNEL" :colon="false" :label-col="{ sm: { span: 8 } }"
|
<a-form v-if="protocol === Protocols.TUNNEL" :colon="false" :label-col="{ sm: { span: 8 } }"
|
||||||
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
||||||
<a-form-item label="Address">
|
<a-form-item label="Rewrite address">
|
||||||
<a-input v-model:value="inbound.settings.address" />
|
<a-input v-model:value="inbound.settings.rewriteAddress" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Destination port">
|
<a-form-item label="Rewrite port">
|
||||||
<a-input-number v-model:value="inbound.settings.port" :min="1" :max="65535" />
|
<a-input-number v-model:value="inbound.settings.rewritePort" :min="0" :max="65535" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Network">
|
<a-form-item label="Allowed network">
|
||||||
<a-select v-model:value="inbound.settings.network">
|
<a-select v-model:value="inbound.settings.allowedNetwork">
|
||||||
<a-select-option value="tcp,udp">TCP, UDP</a-select-option>
|
<a-select-option value="tcp,udp">TCP, UDP</a-select-option>
|
||||||
<a-select-option value="tcp">TCP</a-select-option>
|
<a-select-option value="tcp">TCP</a-select-option>
|
||||||
<a-select-option value="udp">UDP</a-select-option>
|
<a-select-option value="udp">UDP</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="Port map">
|
||||||
|
<a-button size="small" @click="inbound.settings.addPortMap('', '')">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="inbound.settings.portMap.length > 0" :wrapper-col="{ span: 24 }">
|
||||||
|
<a-input-group v-for="(pm, idx) in inbound.settings.portMap" :key="`pm-${idx}`" compact class="mb-8">
|
||||||
|
<a-input :style="{ width: '30%' }" v-model:value="pm.name" placeholder="5555">
|
||||||
|
<template #addonBefore>{{ idx + 1 }}</template>
|
||||||
|
</a-input>
|
||||||
|
<a-input :style="{ width: '60%' }" v-model:value="pm.value" placeholder="1.1.1.1:7777" />
|
||||||
|
<a-button @click="inbound.settings.removePortMap(idx)">
|
||||||
|
<template #icon>
|
||||||
|
<MinusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
</a-form-item>
|
||||||
<a-form-item label="Follow redirect">
|
<a-form-item label="Follow redirect">
|
||||||
<a-switch v-model:checked="inbound.settings.followRedirect" />
|
<a-switch v-model:checked="inbound.settings.followRedirect" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
|
|
||||||
|
<!-- TUN -->
|
||||||
|
<a-form v-if="protocol === Protocols.TUN" :colon="false" :label-col="{ sm: { span: 8 } }"
|
||||||
|
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
||||||
|
<a-form-item label="Interface name">
|
||||||
|
<a-input v-model:value="inbound.settings.name" placeholder="xray0" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="MTU">
|
||||||
|
<a-input-number v-model:value="inbound.settings.mtu" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Gateway">
|
||||||
|
<a-button size="small" @click="inbound.settings.gateway.push('')">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
<a-input v-for="(_ip, j) in inbound.settings.gateway" :key="`tun-gw-${j}`"
|
||||||
|
v-model:value="inbound.settings.gateway[j]" class="mt-4"
|
||||||
|
:placeholder="j === 0 ? '10.0.0.1/16' : 'fc00::1/64'">
|
||||||
|
<template #addonAfter>
|
||||||
|
<a-button size="small" @click="inbound.settings.gateway.splice(j, 1)">
|
||||||
|
<template #icon>
|
||||||
|
<MinusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="DNS">
|
||||||
|
<a-button size="small" @click="inbound.settings.dns.push('')">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
<a-input v-for="(_ip, j) in inbound.settings.dns" :key="`tun-dns-${j}`"
|
||||||
|
v-model:value="inbound.settings.dns[j]" class="mt-4" :placeholder="j === 0 ? '1.1.1.1' : '8.8.8.8'">
|
||||||
|
<template #addonAfter>
|
||||||
|
<a-button size="small" @click="inbound.settings.dns.splice(j, 1)">
|
||||||
|
<template #icon>
|
||||||
|
<MinusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="User level">
|
||||||
|
<a-input-number v-model:value="inbound.settings.userLevel" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip
|
||||||
|
title="Windows-only. CIDRs added to the system routing table automatically so matching traffic goes through TUN.">
|
||||||
|
Auto system routes
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-button size="small" @click="inbound.settings.autoSystemRoutingTable.push('')">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
<a-input v-for="(_ip, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-rt-${j}`"
|
||||||
|
v-model:value="inbound.settings.autoSystemRoutingTable[j]" class="mt-4"
|
||||||
|
:placeholder="j === 0 ? '0.0.0.0/0' : '::/0'">
|
||||||
|
<template #addonAfter>
|
||||||
|
<a-button size="small" @click="inbound.settings.autoSystemRoutingTable.splice(j, 1)">
|
||||||
|
<template #icon>
|
||||||
|
<MinusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip
|
||||||
|
title='Physical interface for outbound traffic. Use "auto" to detect; auto-enabled when Auto system routes is set.'>
|
||||||
|
Auto outbounds interface
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="inbound.settings.autoOutboundsInterface" placeholder="auto" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
<!-- WireGuard -->
|
<!-- WireGuard -->
|
||||||
<a-form v-if="protocol === Protocols.WIREGUARD" :colon="false" :label-col="{ sm: { span: 8 } }"
|
<a-form v-if="protocol === Protocols.WIREGUARD" :colon="false" :label-col="{ sm: { span: 8 } }"
|
||||||
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
||||||
@@ -1644,6 +1822,98 @@ watch(
|
|||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- ====== Hysteria stream settings ====== -->
|
||||||
|
<!-- Per https://xtls.github.io/config/transports/hysteria.html -->
|
||||||
|
<template v-if="protocol === Protocols.HYSTERIA">
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Hysteria protocol version. Currently must be 2.">
|
||||||
|
Version
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input-number v-model:value="inbound.stream.hysteria.version" :min="2" :max="2" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Obfuscation password. Must match between server and client.">
|
||||||
|
Obfs password
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="inbound.stream.hysteria.auth" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Idle timeout (seconds) for a single QUIC native UDP connection.">
|
||||||
|
UDP idle timeout
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input-number v-model:value="inbound.stream.hysteria.udpIdleTimeout" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Masquerade">
|
||||||
|
<a-switch v-model:checked="inbound.stream.hysteria.masqueradeSwitch" />
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="inbound.stream.hysteria.masqueradeSwitch">
|
||||||
|
<a-form-item label="Type">
|
||||||
|
<a-select v-model:value="inbound.stream.hysteria.masquerade.type" :style="{ width: '50%' }">
|
||||||
|
<a-select-option value="proxy">Proxy</a-select-option>
|
||||||
|
<a-select-option value="file">File</a-select-option>
|
||||||
|
<a-select-option value="string">String</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<!-- Proxy type: url / rewriteHost / insecure -->
|
||||||
|
<template v-if="inbound.stream.hysteria.masquerade.type === 'proxy'">
|
||||||
|
<a-form-item label="URL">
|
||||||
|
<a-input v-model:value="inbound.stream.hysteria.masquerade.url" placeholder="https://example.com" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Rewrite Host">
|
||||||
|
<a-switch v-model:checked="inbound.stream.hysteria.masquerade.rewriteHost" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Insecure">
|
||||||
|
<a-switch v-model:checked="inbound.stream.hysteria.masquerade.insecure" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- File type: dir -->
|
||||||
|
<a-form-item v-if="inbound.stream.hysteria.masquerade.type === 'file'" label="Directory">
|
||||||
|
<a-input v-model:value="inbound.stream.hysteria.masquerade.dir" placeholder="/path/to/www" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<!-- String type: content / statusCode / headers -->
|
||||||
|
<template v-if="inbound.stream.hysteria.masquerade.type === 'string'">
|
||||||
|
<a-form-item label="Content">
|
||||||
|
<a-textarea v-model:value="inbound.stream.hysteria.masquerade.content"
|
||||||
|
:auto-size="{ minRows: 2, maxRows: 6 }" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Status Code">
|
||||||
|
<a-input-number v-model:value="inbound.stream.hysteria.masquerade.statusCode" :min="100" :max="599"
|
||||||
|
placeholder="200" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Headers">
|
||||||
|
<a-button size="small" @click="inbound.stream.hysteria.masquerade.addHeader('', '')">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="inbound.stream.hysteria.masquerade.headers.length > 0" :wrapper-col="{ span: 24 }">
|
||||||
|
<a-input-group v-for="(h, idx) in inbound.stream.hysteria.masquerade.headers" :key="`mh-${idx}`"
|
||||||
|
compact class="mb-8">
|
||||||
|
<a-input :style="{ width: '45%' }" v-model:value="h.name" placeholder="Name">
|
||||||
|
<template #addonBefore>{{ idx + 1 }}</template>
|
||||||
|
</a-input>
|
||||||
|
<a-input :style="{ width: '45%' }" v-model:value="h.value" placeholder="Value" />
|
||||||
|
<a-button @click="inbound.stream.hysteria.masquerade.removeHeader(idx)">
|
||||||
|
<template #icon>
|
||||||
|
<MinusOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</a-form>
|
</a-form>
|
||||||
|
|
||||||
<!-- ====== FinalMask (TCP/UDP masks + QUIC params) ====== -->
|
<!-- ====== FinalMask (TCP/UDP masks + QUIC params) ====== -->
|
||||||
@@ -1687,16 +1957,13 @@ watch(
|
|||||||
class="mb-12" />
|
class="mb-12" />
|
||||||
<a-form layout="vertical">
|
<a-form layout="vertical">
|
||||||
<a-form-item label="settings (clients, encryption, fallbacks, …)">
|
<a-form-item label="settings (clients, encryption, fallbacks, …)">
|
||||||
<a-textarea v-model:value="advancedJson.settings" :auto-size="{ minRows: 10, maxRows: 24 }"
|
<JsonEditor v-model:value="advancedJson.settings" min-height="280px" max-height="520px" />
|
||||||
spellcheck="false" class="json-editor" />
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="streamSettings">
|
<a-form-item label="streamSettings">
|
||||||
<a-textarea v-model:value="advancedJson.stream" :auto-size="{ minRows: 10, maxRows: 24 }" spellcheck="false"
|
<JsonEditor v-model:value="advancedJson.stream" min-height="280px" max-height="520px" />
|
||||||
class="json-editor" />
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="sniffing (overrides the Sniffing tab when set)">
|
<a-form-item label="sniffing (overrides the Sniffing tab when set)">
|
||||||
<a-textarea v-model:value="advancedJson.sniffing" :auto-size="{ minRows: 6, maxRows: 16 }"
|
<JsonEditor v-model:value="advancedJson.sniffing" min-height="180px" max-height="360px" />
|
||||||
spellcheck="false" class="json-editor" />
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
@@ -1741,9 +2008,9 @@ watch(
|
|||||||
color: #ff4d4f;
|
color: #ff4d4f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-editor {
|
.vless-auth-state {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
display: block;
|
||||||
font-size: 12px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-summary {
|
.client-summary {
|
||||||
|
|||||||
@@ -387,6 +387,9 @@ const showSubscriptionTab = computed(
|
|||||||
<td>
|
<td>
|
||||||
<a-tag v-if="clientStats && clientSettings.totalGB > 0" :color="statsColor(clientStats)">{{
|
<a-tag v-if="clientStats && clientSettings.totalGB > 0" :color="statsColor(clientStats)">{{
|
||||||
getRemainingStats() }}</a-tag>
|
getRemainingStats() }}</a-tag>
|
||||||
|
<a-tag v-else-if="!clientSettings.totalGB || clientSettings.totalGB <= 0" color="purple">
|
||||||
|
<InfinityIcon />
|
||||||
|
</a-tag>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a-tag v-if="clientSettings.totalGB > 0" :color="clientStats ? statsColor(clientStats) : 'default'">{{
|
<a-tag v-if="clientSettings.totalGB > 0" :color="clientStats ? statsColor(clientStats) : 'default'">{{
|
||||||
@@ -582,19 +585,50 @@ const showSubscriptionTab = computed(
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<!-- TUN -->
|
||||||
|
<dl v-if="inbound.protocol === Protocols.TUN" class="info-list info-list-block">
|
||||||
|
<div class="info-row">
|
||||||
|
<dt>Interface name</dt>
|
||||||
|
<dd><a-tag color="green" class="value-tag">{{ inbound.settings.name }}</a-tag></dd>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<dt>MTU</dt>
|
||||||
|
<dd><a-tag color="green">{{ inbound.settings.mtu }}</a-tag></dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="inbound.settings.gateway?.length" class="info-row">
|
||||||
|
<dt>Gateway</dt>
|
||||||
|
<dd><a-tag v-for="(ip, j) in inbound.settings.gateway" :key="`tun-i-gw-${j}`" color="green"
|
||||||
|
class="value-tag">{{ ip }}</a-tag></dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="inbound.settings.dns?.length" class="info-row">
|
||||||
|
<dt>DNS</dt>
|
||||||
|
<dd><a-tag v-for="(ip, j) in inbound.settings.dns" :key="`tun-i-dns-${j}`" color="green">{{ ip }}</a-tag>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<dt>Outbounds interface</dt>
|
||||||
|
<dd><a-tag color="green">{{ inbound.settings.autoOutboundsInterface || 'auto' }}</a-tag></dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="inbound.settings.autoSystemRoutingTable?.length" class="info-row">
|
||||||
|
<dt>Auto system routes</dt>
|
||||||
|
<dd><a-tag v-for="(cidr, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-i-rt-${j}`"
|
||||||
|
color="green">{{ cidr }}</a-tag></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
<!-- Tunnel -->
|
<!-- Tunnel -->
|
||||||
<dl v-if="inbound.protocol === Protocols.TUNNEL" class="info-list info-list-block">
|
<dl v-if="inbound.protocol === Protocols.TUNNEL" class="info-list info-list-block">
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<dt>{{ t('pages.inbounds.targetAddress') }}</dt>
|
<dt>{{ t('pages.inbounds.targetAddress') }}</dt>
|
||||||
<dd><a-tag color="green" class="value-tag">{{ inbound.settings.address }}</a-tag></dd>
|
<dd><a-tag color="green" class="value-tag">{{ inbound.settings.rewriteAddress }}</a-tag></dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<dt>{{ t('pages.inbounds.destinationPort') }}</dt>
|
<dt>{{ t('pages.inbounds.destinationPort') }}</dt>
|
||||||
<dd><a-tag color="green">{{ inbound.settings.port }}</a-tag></dd>
|
<dd><a-tag color="green">{{ inbound.settings.rewritePort }}</a-tag></dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<dt>{{ t('pages.inbounds.network') }}</dt>
|
<dt>{{ t('pages.inbounds.network') }}</dt>
|
||||||
<dd><a-tag color="green">{{ inbound.settings.network }}</a-tag></dd>
|
<dd><a-tag color="green">{{ inbound.settings.allowedNetwork }}</a-tag></dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<dt>FollowRedirect</dt>
|
<dt>FollowRedirect</dt>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -49,6 +49,8 @@ const props = defineProps({
|
|||||||
// Map node id -> node row, supplied by the parent page so each
|
// Map node id -> node row, supplied by the parent page so each
|
||||||
// inbound row can render its node name without an extra fetch.
|
// inbound row can render its node name without an extra fetch.
|
||||||
nodesById: { type: Map, default: () => new Map() },
|
nodesById: { type: Map, default: () => new Map() },
|
||||||
|
hasActiveNode: { type: Boolean, default: false },
|
||||||
|
statsVersion: { type: Number, default: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -62,13 +64,34 @@ const emit = defineEmits([
|
|||||||
'info-client',
|
'info-client',
|
||||||
'reset-traffic-client',
|
'reset-traffic-client',
|
||||||
'delete-client',
|
'delete-client',
|
||||||
|
'delete-clients',
|
||||||
'toggle-enable-client',
|
'toggle-enable-client',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ============ Toolbar / search & filter =============================
|
// ============ Toolbar / search & filter =============================
|
||||||
const enableFilter = ref(false);
|
const FILTER_STATE_KEY = 'inboundsFilterState';
|
||||||
const searchKey = ref('');
|
const savedFilterState = (() => {
|
||||||
const filterBy = ref('');
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
|
||||||
|
} catch (_e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const enableFilter = ref(!!savedFilterState.enableFilter);
|
||||||
|
const searchKey = ref(savedFilterState.searchKey || '');
|
||||||
|
const filterBy = ref(savedFilterState.filterBy || '');
|
||||||
|
const protocolFilter = ref(savedFilterState.protocolFilter || '');
|
||||||
|
const nodeFilter = ref(savedFilterState.nodeFilter || '');
|
||||||
|
|
||||||
|
watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => {
|
||||||
|
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
|
||||||
|
enableFilter: enableFilter.value,
|
||||||
|
searchKey: searchKey.value,
|
||||||
|
filterBy: filterBy.value,
|
||||||
|
protocolFilter: protocolFilter.value,
|
||||||
|
nodeFilter: nodeFilter.value,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
// Toggle the filter mode — flip cleans the other input.
|
// Toggle the filter mode — flip cleans the other input.
|
||||||
function onToggleFilter() {
|
function onToggleFilter() {
|
||||||
@@ -76,6 +99,35 @@ function onToggleFilter() {
|
|||||||
else filterBy.value = '';
|
else filterBy.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const protocolOptions = computed(() => {
|
||||||
|
const values = new Set(props.dbInbounds.map((i) => i.protocol).filter(Boolean));
|
||||||
|
return [...values].sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeOptions = computed(() => {
|
||||||
|
const values = new Map();
|
||||||
|
if (props.dbInbounds.some((i) => i.nodeId == null)) {
|
||||||
|
values.set('local', t('pages.inbounds.localPanel'));
|
||||||
|
}
|
||||||
|
for (const dbInbound of props.dbInbounds) {
|
||||||
|
if (dbInbound.nodeId == null) continue;
|
||||||
|
const node = props.nodesById.get(dbInbound.nodeId);
|
||||||
|
values.set(String(dbInbound.nodeId), node?.name || `#${dbInbound.nodeId}`);
|
||||||
|
}
|
||||||
|
return [...values.entries()].map(([value, label]) => ({ value, label }));
|
||||||
|
});
|
||||||
|
|
||||||
|
function applySecondaryFilters(rows) {
|
||||||
|
return rows.filter((dbInbound) => {
|
||||||
|
if (protocolFilter.value && dbInbound.protocol !== protocolFilter.value) return false;
|
||||||
|
if (nodeFilter.value) {
|
||||||
|
const nodeValue = dbInbound.nodeId == null ? 'local' : String(dbInbound.nodeId);
|
||||||
|
if (nodeValue !== nodeFilter.value) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Search / filter projection =============================
|
// ============ Search / filter projection =============================
|
||||||
// Mirrors the legacy logic: when searching, keep inbounds that match
|
// Mirrors the legacy logic: when searching, keep inbounds that match
|
||||||
// anywhere (deep search); when filtering, keep inbounds that have at
|
// anywhere (deep search); when filtering, keep inbounds that have at
|
||||||
@@ -98,7 +150,7 @@ function projectInbound(dbInbound, predicate) {
|
|||||||
|
|
||||||
const visibleInbounds = computed(() => {
|
const visibleInbounds = computed(() => {
|
||||||
if (enableFilter.value) {
|
if (enableFilter.value) {
|
||||||
if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds];
|
if (ObjectUtil.isEmpty(filterBy.value)) return applySecondaryFilters([...props.dbInbounds]);
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const dbInbound of props.dbInbounds) {
|
for (const dbInbound of props.dbInbounds) {
|
||||||
const c = props.clientCount[dbInbound.id];
|
const c = props.clientCount[dbInbound.id];
|
||||||
@@ -106,38 +158,94 @@ const visibleInbounds = computed(() => {
|
|||||||
const list = c[filterBy.value];
|
const list = c[filterBy.value];
|
||||||
out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
|
out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
|
||||||
}
|
}
|
||||||
return out;
|
return applySecondaryFilters(out);
|
||||||
}
|
}
|
||||||
if (ObjectUtil.isEmpty(searchKey.value)) return [...props.dbInbounds];
|
if (ObjectUtil.isEmpty(searchKey.value)) return applySecondaryFilters([...props.dbInbounds]);
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const dbInbound of props.dbInbounds) {
|
for (const dbInbound of props.dbInbounds) {
|
||||||
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
|
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
|
||||||
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
|
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
|
||||||
}
|
}
|
||||||
return out;
|
return applySecondaryFilters(out);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Sorting =================================================
|
||||||
|
const sortState = ref({ column: null, order: null });
|
||||||
|
|
||||||
|
function sortableCol(col, key) {
|
||||||
|
return {
|
||||||
|
...col,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
sortOrder: sortState.value.column === key ? sortState.value.order : null,
|
||||||
|
sortDirections: ['ascend', 'descend'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortFns = {
|
||||||
|
id: (a, b) => a.id - b.id,
|
||||||
|
enable: (a, b) => Number(a.enable) - Number(b.enable),
|
||||||
|
remark: (a, b) => (a.remark || '').localeCompare(b.remark || ''),
|
||||||
|
port: (a, b) => a.port - b.port,
|
||||||
|
protocol: (a, b) => a.protocol.localeCompare(b.protocol),
|
||||||
|
traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
|
||||||
|
allTimeInbound: (a, b) => (a.allTime || 0) - (b.allTime || 0),
|
||||||
|
expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
|
||||||
|
node: (a, b) => {
|
||||||
|
const nameA = props.nodesById.get(a.nodeId)?.name ?? (a.nodeId == null ? '\uffff' : `node #${a.nodeId}`);
|
||||||
|
const nameB = props.nodesById.get(b.nodeId)?.name ?? (b.nodeId == null ? '\uffff' : `node #${b.nodeId}`);
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
},
|
||||||
|
clients: (a, b) => (props.clientCount[a.id]?.clients || 0) - (props.clientCount[b.id]?.clients || 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedInbounds = computed(() => {
|
||||||
|
const { column, order } = sortState.value;
|
||||||
|
if (!column || !order) return visibleInbounds.value;
|
||||||
|
const fn = sortFns[column];
|
||||||
|
if (!fn) return visibleInbounds.value;
|
||||||
|
const sorted = [...visibleInbounds.value].sort(fn);
|
||||||
|
return order === 'descend' ? sorted.reverse() : sorted;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onTableChange(_pag, _filters, sorter) {
|
||||||
|
sortState.value = {
|
||||||
|
column: sorter?.columnKey || sorter?.field || null,
|
||||||
|
order: sorter?.order || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([searchKey, filterBy], () => {
|
||||||
|
sortState.value = { column: null, order: null };
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ Columns =================================================
|
// ============ Columns =================================================
|
||||||
// `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
|
// `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
|
// `responsive` array still works on column defs. Computed so column
|
||||||
// labels react to live locale switches.
|
// labels react to live locale switches.
|
||||||
|
const hasAnyRemark = computed(() =>
|
||||||
|
props.dbInbounds.some((i) => typeof i?.remark === 'string' && i.remark.trim() !== ''),
|
||||||
|
);
|
||||||
|
|
||||||
const desktopColumns = computed(() => {
|
const desktopColumns = computed(() => {
|
||||||
const cols = [
|
const cols = [
|
||||||
{ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30, responsive: ['xs'] },
|
sortableCol({ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 }, 'id'),
|
||||||
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
|
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 60 },
|
||||||
{ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 },
|
sortableCol({ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, 'enable'),
|
||||||
{ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 },
|
|
||||||
];
|
];
|
||||||
if (props.nodesById.size > 0) {
|
if (hasAnyRemark.value) {
|
||||||
cols.push({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 });
|
cols.push(sortableCol({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 }, 'remark'));
|
||||||
|
}
|
||||||
|
if (props.hasActiveNode) {
|
||||||
|
cols.push(sortableCol({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 }, 'node'));
|
||||||
}
|
}
|
||||||
cols.push(
|
cols.push(
|
||||||
{ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 },
|
sortableCol({ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 }, 'port'),
|
||||||
{ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 },
|
sortableCol({ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, 'protocol'),
|
||||||
{ title: t('clients'), key: 'clients', align: 'left', width: 50 },
|
sortableCol({ title: t('clients'), key: 'clients', align: 'left', width: 50 }, 'clients'),
|
||||||
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 },
|
sortableCol({ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, 'traffic'),
|
||||||
{ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 },
|
sortableCol({ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 }, 'allTimeInbound'),
|
||||||
{ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 },
|
sortableCol({ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
|
||||||
);
|
);
|
||||||
return cols;
|
return cols;
|
||||||
});
|
});
|
||||||
@@ -156,6 +264,14 @@ function isExpanded(id) {
|
|||||||
return expandedIds.value.has(id);
|
return expandedIds.value.has(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statsRecord = ref(null);
|
||||||
|
function openStats(record) {
|
||||||
|
statsRecord.value = record;
|
||||||
|
}
|
||||||
|
function closeStats() {
|
||||||
|
statsRecord.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Pagination ============================================
|
// ============ Pagination ============================================
|
||||||
function paginationFor(rows) {
|
function paginationFor(rows) {
|
||||||
const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
|
const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
|
||||||
@@ -262,20 +378,35 @@ function showQrCodeMenu(dbInbound) {
|
|||||||
<a-radio-button value="expiring">{{ t('depletingSoon') }}</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-button value="online">{{ t('online') }}</a-radio-button>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
|
<a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
|
||||||
|
:size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
|
||||||
|
<a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
|
||||||
|
{{ protocol }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<a-select v-if="hasActiveNode && nodeOptions.length > 0" v-model:value="nodeFilter" allow-clear
|
||||||
|
:placeholder="t('pages.inbounds.node')" :size="isMobile ? 'small' : 'middle'" :style="{ width: '170px' }">
|
||||||
|
<a-select-option v-for="node in nodeOptions" :key="node.value" :value="node.value">
|
||||||
|
{{ node.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ====================== Mobile: card list ======================= -->
|
<!-- ====================== Mobile: card list ======================= -->
|
||||||
<div v-if="isMobile" class="inbound-cards">
|
<div v-if="isMobile" class="inbound-cards">
|
||||||
<div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
|
<div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
|
||||||
|
|
||||||
<div v-for="record in visibleInbounds" :key="record.id" class="inbound-card">
|
<div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
|
||||||
<!-- Header: chevron (multi-user only) + remark + enable + actions -->
|
<!-- Header: chevron (multi-user only) + id + remark + info + enable + actions -->
|
||||||
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
|
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
|
||||||
<RightOutlined v-if="record.isMultiUser()" class="card-expand"
|
<RightOutlined v-if="record.isMultiUser()" class="card-expand"
|
||||||
:class="{ 'is-expanded': isExpanded(record.id) }" />
|
:class="{ 'is-expanded': isExpanded(record.id) }" />
|
||||||
<span class="card-id">#{{ record.id }}</span>
|
<span class="card-id">#{{ record.id }}</span>
|
||||||
<span class="tag-name">{{ record.remark }}</span>
|
<span class="tag-name">{{ record.remark }}</span>
|
||||||
<div class="card-actions" @click.stop>
|
<div class="card-actions" @click.stop>
|
||||||
|
<a-tooltip :title="t('info')">
|
||||||
|
<InfoCircleOutlined class="row-action-trigger" @click="openStats(record)" />
|
||||||
|
</a-tooltip>
|
||||||
<a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
|
<a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
|
||||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||||
<MoreOutlined class="row-action-trigger" @click.prevent />
|
<MoreOutlined class="row-action-trigger" @click.prevent />
|
||||||
@@ -333,155 +464,175 @@ function showQrCodeMenu(dbInbound) {
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Expanded client list (multi-user only) -->
|
||||||
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
|
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
|
||||||
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
|
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
|
||||||
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
|
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
|
||||||
|
:page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
|
||||||
|
:stats-version="statsVersion"
|
||||||
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
|
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
|
||||||
@info-client="(p) => emit('info-client', p)"
|
@info-client="(p) => emit('info-client', p)"
|
||||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||||
@delete-client="(p) => emit('delete-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)" />
|
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================== Mobile: info modal ====================== -->
|
||||||
|
<a-modal v-if="isMobile" :open="!!statsRecord" :footer="null" :width="360" centered
|
||||||
|
:title="statsRecord ? `#${statsRecord.id} ${statsRecord.remark || ''}`.trim() : ''" @cancel="closeStats">
|
||||||
|
<div v-if="statsRecord" class="card-stats">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
|
||||||
|
<a-tag color="purple">{{ statsRecord.protocol }}</a-tag>
|
||||||
|
<template
|
||||||
|
v-if="statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria">
|
||||||
|
<a-tag color="green">{{ statsRecord.isHysteria ? 'UDP' : statsRecord.toInbound().stream.network }}</a-tag>
|
||||||
|
<a-tag v-if="statsRecord.toInbound().stream.isTls" color="blue">TLS</a-tag>
|
||||||
|
<a-tag v-if="statsRecord.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>{{ statsRecord.port }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasActiveNode" class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.node') }}</span>
|
||||||
|
<a-tag v-if="statsRecord.nodeId == null" color="default">
|
||||||
|
{{ t('pages.inbounds.localPanel') }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else-if="nodesById.get(statsRecord.nodeId)"
|
||||||
|
:color="nodesById.get(statsRecord.nodeId).status === 'online' ? 'blue' : 'red'">
|
||||||
|
{{ nodesById.get(statsRecord.nodeId).name }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else color="orange">#{{ statsRecord.nodeId }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
||||||
|
<a-tag :color="ColorUtils.usageColor(statsRecord.up + statsRecord.down, trafficDiff, statsRecord.total)">
|
||||||
|
{{ SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down) }} /
|
||||||
|
<template v-if="statsRecord.total > 0">{{ SizeFormatter.sizeFormat(statsRecord.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(statsRecord.allTime || 0) }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="clientCount[statsRecord.id]" class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('clients') }}</span>
|
||||||
|
<a-tag color="green" class="client-count-tag">{{ clientCount[statsRecord.id].clients }}</a-tag>
|
||||||
|
<a-tag v-if="clientCount[statsRecord.id].online.length" color="blue">
|
||||||
|
{{ clientCount[statsRecord.id].online.length }} {{ t('online') }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="clientCount[statsRecord.id].depleted.length" color="red">
|
||||||
|
{{ clientCount[statsRecord.id].depleted.length }} {{ t('depleted') }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="clientCount[statsRecord.id].expiring.length" color="orange">
|
||||||
|
{{ clientCount[statsRecord.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="statsRecord.expiryTime > 0"
|
||||||
|
:color="ColorUtils.usageColor(Date.now(), expireDiff, statsRecord._expiryTime)">
|
||||||
|
{{ IntlUtil.formatRelativeTime(statsRecord.expiryTime) }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else color="purple">
|
||||||
|
<InfinityIcon />
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
<!-- ====================== Desktop: a-table ======================== -->
|
<!-- ====================== Desktop: a-table ======================== -->
|
||||||
<a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
|
<a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
|
||||||
:pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
|
:pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
|
||||||
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
|
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')" @change="onTableChange">
|
||||||
<!-- Per-inbound client list, expanded by clicking the row's
|
<!-- Per-inbound client list, expanded by clicking the row's
|
||||||
default expand chevron. Hidden via row-class-name for
|
default expand chevron. Hidden via row-class-name for
|
||||||
non-multi-user inbounds (matches legacy behavior). -->
|
non-multi-user inbounds (matches legacy behavior). -->
|
||||||
<template #expandedRowRender="{ record }">
|
<template #expandedRowRender="{ record }">
|
||||||
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
|
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
|
||||||
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
|
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
|
||||||
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" @edit-client="(p) => emit('edit-client', p)"
|
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
|
||||||
|
:total-client-count="clientCount[record.id]?.clients || 0"
|
||||||
|
:stats-version="statsVersion"
|
||||||
|
@edit-client="(p) => emit('edit-client', p)"
|
||||||
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-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)"
|
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||||
@delete-client="(p) => emit('delete-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)" />
|
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<!-- ============== Action dropdown ============== -->
|
<!-- ============== Action dropdown ============== -->
|
||||||
<template v-if="column.key === 'action'">
|
<template v-if="column.key === 'action'">
|
||||||
<a-dropdown :trigger="['click']">
|
<div class="action-buttons">
|
||||||
<MoreOutlined class="row-action-trigger" @click.prevent />
|
<a-button type="text" size="small" @click.prevent="emit('row-action', {key: 'edit', dbInbound: record})">
|
||||||
<template #overlay>
|
<template #icon>
|
||||||
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
|
<EditOutlined />
|
||||||
<a-menu-item key="edit">
|
</template>
|
||||||
<EditOutlined /> {{ t('edit') }}
|
</a-button>
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
|
<a-dropdown :trigger="['click']">
|
||||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
<a-button type="text" size="small" @click.prevent>
|
||||||
</a-menu-item>
|
<template #icon>
|
||||||
<template v-if="record.isMultiUser()">
|
<MoreOutlined />
|
||||||
<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>
|
||||||
<template v-else>
|
</a-button>
|
||||||
<a-menu-item key="showInfo">
|
<template #overlay>
|
||||||
<InfoCircleOutlined /> {{ t('info') }}
|
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
|
||||||
|
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
|
||||||
|
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
</template>
|
<template v-if="record.isMultiUser()">
|
||||||
<a-menu-item key="clipboard">
|
<a-menu-item key="addClient">
|
||||||
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
|
<UserAddOutlined /> {{ t('pages.client.add') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item key="resetTraffic">
|
<a-menu-item key="addBulkClient">
|
||||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item key="clone">
|
<a-menu-item key="copyClients">
|
||||||
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
|
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item key="delete" class="danger-item">
|
<a-menu-item key="resetClients">
|
||||||
<DeleteOutlined /> {{ t('delete') }}
|
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
</a-menu>
|
<a-menu-item key="export">
|
||||||
</template>
|
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||||
</a-dropdown>
|
</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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ============== Enable switch (desktop) ============== -->
|
<!-- ============== Enable switch (desktop) ============== -->
|
||||||
@@ -509,8 +660,8 @@ function showQrCodeMenu(dbInbound) {
|
|||||||
<template v-else-if="column.key === 'protocol'">
|
<template v-else-if="column.key === 'protocol'">
|
||||||
<div class="protocol-tags">
|
<div class="protocol-tags">
|
||||||
<a-tag color="purple">{{ record.protocol }}</a-tag>
|
<a-tag color="purple">{{ record.protocol }}</a-tag>
|
||||||
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
|
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria">
|
||||||
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
|
<a-tag color="green">{{ record.isHysteria ? 'UDP' : 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.isTls" color="blue">TLS</a-tag>
|
||||||
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
|
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
|
||||||
</template>
|
</template>
|
||||||
@@ -520,32 +671,40 @@ function showQrCodeMenu(dbInbound) {
|
|||||||
<!-- ============== Clients tag + popovers ============== -->
|
<!-- ============== Clients tag + popovers ============== -->
|
||||||
<template v-else-if="column.key === 'clients'">
|
<template v-else-if="column.key === 'clients'">
|
||||||
<template v-if="clientCount[record.id]">
|
<template v-if="clientCount[record.id]">
|
||||||
<a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag>
|
<a-tag color="green" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].clients }}</a-tag>
|
||||||
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
|
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
|
<div class="client-email-list">
|
||||||
|
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
|
<a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
|
||||||
</a-popover>
|
</a-popover>
|
||||||
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
|
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
|
<div class="client-email-list">
|
||||||
|
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
|
<a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
|
||||||
}}</a-tag>
|
}}</a-tag>
|
||||||
</a-popover>
|
</a-popover>
|
||||||
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
|
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
|
<div class="client-email-list">
|
||||||
|
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
|
<a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
|
||||||
}}</a-tag>
|
}}</a-tag>
|
||||||
</a-popover>
|
</a-popover>
|
||||||
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
|
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
|
<div class="client-email-list">
|
||||||
|
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
|
<a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
|
||||||
</a-popover>
|
</a-popover>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -611,7 +770,14 @@ function showQrCodeMenu(dbInbound) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-bar.mobile>* {
|
.filter-bar.mobile>* {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.protocol-tags {
|
.protocol-tags {
|
||||||
@@ -620,6 +786,10 @@ function showQrCodeMenu(dbInbound) {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-count-tag {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.row-action-trigger {
|
.row-action-trigger {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import InboundList from './InboundList.vue';
|
|||||||
import InboundFormModal from './InboundFormModal.vue';
|
import InboundFormModal from './InboundFormModal.vue';
|
||||||
import ClientFormModal from './ClientFormModal.vue';
|
import ClientFormModal from './ClientFormModal.vue';
|
||||||
import ClientBulkModal from './ClientBulkModal.vue';
|
import ClientBulkModal from './ClientBulkModal.vue';
|
||||||
|
import CopyClientsModal from './CopyClientsModal.vue';
|
||||||
import InboundInfoModal from './InboundInfoModal.vue';
|
import InboundInfoModal from './InboundInfoModal.vue';
|
||||||
import QrCodeModal from './QrCodeModal.vue';
|
import QrCodeModal from './QrCodeModal.vue';
|
||||||
import TextModal from '@/components/TextModal.vue';
|
import TextModal from '@/components/TextModal.vue';
|
||||||
@@ -44,6 +45,7 @@ const {
|
|||||||
ipLimitEnable,
|
ipLimitEnable,
|
||||||
remarkModel,
|
remarkModel,
|
||||||
lastOnlineMap,
|
lastOnlineMap,
|
||||||
|
statsVersion,
|
||||||
refresh,
|
refresh,
|
||||||
fetchDefaultSettings,
|
fetchDefaultSettings,
|
||||||
applyTrafficEvent,
|
applyTrafficEvent,
|
||||||
@@ -65,9 +67,9 @@ useWebSocket({
|
|||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
// Node list lives on the central panel; the Inbounds page consumes
|
// Node list lives on the central panel; the Inbounds page consumes
|
||||||
// the id→node map for the new "Node" column. Fetched once on mount.
|
// the id→node map for the new "Node" column. Fetched once on mount.
|
||||||
const { byId: nodesById } = useNodeList();
|
const { byId: nodesById, hasActive: hasActiveNode } = useNodeList();
|
||||||
|
|
||||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
const requestUri = window.location.pathname;
|
const requestUri = window.location.pathname;
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -88,6 +90,8 @@ const clientIndex = ref(null);
|
|||||||
|
|
||||||
const bulkOpen = ref(false);
|
const bulkOpen = ref(false);
|
||||||
const bulkDbInbound = ref(null);
|
const bulkDbInbound = ref(null);
|
||||||
|
const copyOpen = ref(false);
|
||||||
|
const copyDbInbound = ref(null);
|
||||||
|
|
||||||
// === Info / QR-code modals ===========================================
|
// === Info / QR-code modals ===========================================
|
||||||
const infoOpen = ref(false);
|
const infoOpen = ref(false);
|
||||||
@@ -322,6 +326,14 @@ async function onDeleteClient({ dbInbound, client }) {
|
|||||||
if (msg?.success) await refresh();
|
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 }) {
|
async function onToggleEnableClient({ dbInbound, client, next }) {
|
||||||
// Mirror legacy: clone the parsed inbound, flip enable on the matching
|
// Mirror legacy: clone the parsed inbound, flip enable on the matching
|
||||||
// client, and post the whole client back through updateClient. This
|
// client, and post the whole client back through updateClient. This
|
||||||
@@ -385,7 +397,7 @@ function confirmResetTraffic(dbInbound) {
|
|||||||
okText: 'Reset',
|
okText: 'Reset',
|
||||||
cancelText: 'Cancel',
|
cancelText: 'Cancel',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllTraffics`);
|
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
|
||||||
if (msg?.success) await refresh();
|
if (msg?.success) await refresh();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -507,10 +519,8 @@ function onRowAction({ key, dbInbound }) {
|
|||||||
exportInboundClipboard(dbInbound);
|
exportInboundClipboard(dbInbound);
|
||||||
break;
|
break;
|
||||||
case 'copyClients':
|
case 'copyClients':
|
||||||
// Copy-clients-from-inbound is a tiny dedicated modal in legacy
|
copyDbInbound.value = dbInbound;
|
||||||
// (lets you tick clients to copy across inbounds). Defer to a
|
copyOpen.value = true;
|
||||||
// future commit — surface a friendly message for now.
|
|
||||||
message.info('Copy clients across inbounds — coming soon');
|
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
confirmDelete(dbInbound);
|
confirmDelete(dbInbound);
|
||||||
@@ -593,9 +603,38 @@ function onRowAction({ key, dbInbound }) {
|
|||||||
<a-space direction="horizontal">
|
<a-space direction="horizontal">
|
||||||
<TeamOutlined />
|
<TeamOutlined />
|
||||||
<a-tag color="green">{{ totals.clients }}</a-tag>
|
<a-tag color="green">{{ totals.clients }}</a-tag>
|
||||||
<a-tag v-if="totals.deactive.length">{{ totals.deactive.length }}</a-tag>
|
<a-popover v-if="totals.deactive.length" :title="t('disabled')">
|
||||||
<a-tag v-if="totals.depleted.length" color="red">{{ totals.depleted.length }}</a-tag>
|
<template #content>
|
||||||
<a-tag v-if="totals.expiring.length" color="orange">{{ totals.expiring.length }}</a-tag>
|
<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>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
</CustomStatistic>
|
</CustomStatistic>
|
||||||
@@ -609,11 +648,13 @@ function onRowAction({ key, dbInbound }) {
|
|||||||
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
|
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
|
||||||
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
|
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
|
||||||
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
|
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
|
||||||
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh"
|
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
|
||||||
|
:stats-version="statsVersion"
|
||||||
|
@refresh="refresh"
|
||||||
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
|
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
|
||||||
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
|
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
|
||||||
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
|
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
|
||||||
@toggle-enable-client="onToggleEnableClient" />
|
@delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
@@ -626,12 +667,14 @@ function onRowAction({ key, dbInbound }) {
|
|||||||
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
|
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
|
||||||
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
|
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
|
||||||
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
|
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
|
||||||
|
<CopyClientsModal v-model:open="copyOpen" :db-inbound="copyDbInbound" :db-inbounds="dbInbounds"
|
||||||
|
@saved="refresh" />
|
||||||
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
|
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
|
||||||
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
||||||
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
|
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
|
||||||
:last-online-map="lastOnlineMap" :node-address="infoNodeAddress" />
|
:last-online-map="lastOnlineMap" :node-address="infoNodeAddress" />
|
||||||
<QrCodeModal v-model:open="qrOpen" :db-inbound="qrDbInbound" :client="qrClient" :remark-model="remarkModel"
|
<QrCodeModal v-model:open="qrOpen" :db-inbound="qrDbInbound" :client="qrClient" :remark-model="remarkModel"
|
||||||
:node-address="qrNodeAddress" />
|
:node-address="qrNodeAddress" :sub-settings="subSettings" />
|
||||||
|
|
||||||
<TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" />
|
<TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" />
|
||||||
<PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType"
|
<PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType"
|
||||||
@@ -650,8 +693,8 @@ function onRowAction({ key, dbInbound }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.inbounds-page.is-dark {
|
.inbounds-page.is-dark {
|
||||||
--bg-page: #0a1222;
|
--bg-page: #1e1e1e;
|
||||||
--bg-card: #151f31;
|
--bg-card: #252526;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inbounds-page.is-dark.is-ultra {
|
.inbounds-page.is-dark.is-ultra {
|
||||||
@@ -692,3 +735,20 @@ function onRowAction({ key, dbInbound }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import { Protocols } from '@/models/inbound.js';
|
import { Protocols } from '@/models/inbound.js';
|
||||||
import QrPanel from './QrPanel.vue';
|
import QrPanel from './QrPanel.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
// Light QR-only modal — used for the "qrcode" row action on
|
|
||||||
// single-user Shadowsocks and WireGuard inbounds. The big info modal
|
|
||||||
// (InboundInfoModal) is too detailed when the user just wants the
|
|
||||||
// share link as a QR.
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
dbInbound: { type: Object, default: null },
|
dbInbound: { type: Object, default: null },
|
||||||
client: { type: Object, default: null },
|
client: { type: Object, default: null },
|
||||||
remarkModel: { type: String, default: '-ieo' },
|
remarkModel: { type: String, default: '-ieo' },
|
||||||
// Address of the node hosting this inbound (empty string for local).
|
|
||||||
// When set, share/QR links use it as the host instead of the panel's
|
|
||||||
// origin — node-managed inbounds proxy from the node, not the panel.
|
|
||||||
nodeAddress: { type: String, default: '' },
|
nodeAddress: { type: String, default: '' },
|
||||||
|
subSettings: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:open']);
|
const emit = defineEmits(['update:open']);
|
||||||
@@ -28,6 +23,50 @@ const emit = defineEmits(['update:open']);
|
|||||||
const links = ref([]);
|
const links = ref([]);
|
||||||
const wireguardConfigs = ref([]);
|
const wireguardConfigs = ref([]);
|
||||||
const wireguardLinks = 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) => {
|
watch(() => props.open, (next) => {
|
||||||
if (!next || !props.dbInbound) return;
|
if (!next || !props.dbInbound) return;
|
||||||
@@ -46,6 +85,20 @@ watch(() => props.open, (next) => {
|
|||||||
wireguardConfigs.value = [];
|
wireguardConfigs.value = [];
|
||||||
wireguardLinks.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');
|
||||||
|
activeKeys.value = open;
|
||||||
});
|
});
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
@@ -56,12 +109,17 @@ function close() {
|
|||||||
<template>
|
<template>
|
||||||
<a-modal :open="open" :title="t('qrCode')" :footer="null" width="420px" @cancel="close">
|
<a-modal :open="open" :title="t('qrCode')" :footer="null" width="420px" @cancel="close">
|
||||||
<template v-if="dbInbound">
|
<template v-if="dbInbound">
|
||||||
<QrPanel v-for="(link, idx) in links" :key="`l${idx}`" :value="link.link"
|
<a-collapse v-model:active-key="activeKeys" ghost class="qr-collapse">
|
||||||
:remark="link.remark || `Link ${idx + 1}`" />
|
<a-collapse-panel v-for="item in qrItems" :key="item.key" :header="item.header">
|
||||||
<template v-for="(cfg, idx) in wireguardConfigs" :key="`w${idx}`">
|
<QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''" />
|
||||||
<QrPanel :value="cfg" :remark="`Peer ${idx + 1} config`" :download-name="`peer-${idx + 1}.conf`" />
|
</a-collapse-panel>
|
||||||
<QrPanel v-if="wireguardLinks[idx]" :value="wireguardLinks[idx]" :remark="`Peer ${idx + 1} link`" />
|
</a-collapse>
|
||||||
</template>
|
|
||||||
</template>
|
</template>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.qr-collapse :deep(.ant-collapse-content-box) {
|
||||||
|
padding: 8px 0 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import QRious from 'qrious';
|
|
||||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
|
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
@@ -9,73 +7,14 @@ import { ClipboardManager, FileManager } from '@/utils';
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
// Renders a single share-link as a clickable QR code + a copy button
|
|
||||||
// + (optional) a download button. Used per-link inside the inbound
|
|
||||||
// info modal — the canvas is repainted whenever `value` changes.
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// The link or config text to encode + display.
|
|
||||||
value: { type: String, required: true },
|
value: { type: String, required: true },
|
||||||
// Header label shown next to the copy button.
|
|
||||||
remark: { type: String, default: '' },
|
remark: { type: String, default: '' },
|
||||||
// Optional download filename — when set, surfaces a download button.
|
|
||||||
downloadName: { type: String, default: '' },
|
downloadName: { type: String, default: '' },
|
||||||
// Final on-screen QR size in CSS pixels. The canvas drawing buffer
|
size: { type: Number, default: 360 },
|
||||||
// is rounded down to a multiple of the QR matrix width (so the QR
|
|
||||||
// fills it edge-to-edge) and CSS then scales the canvas to exactly
|
|
||||||
// this size — so a denser QR (e.g. WireGuard config) and a sparser
|
|
||||||
// one (its link) display at identical dimensions.
|
|
||||||
size: { type: Number, default: 240 },
|
|
||||||
// Toggle the QR rendering off when callers only want the "row of buttons"
|
|
||||||
// styling (used when the legacy panel rendered links without QRs).
|
|
||||||
showQr: { type: Boolean, default: true },
|
showQr: { type: Boolean, default: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const canvas = ref(null);
|
|
||||||
|
|
||||||
// Byte-mode capacities (level M) for QR versions 1..40 — used to pick
|
|
||||||
// the matrix width up front so we can size the canvas as a multiple
|
|
||||||
// of pixelSize. Without this, QRious renders at floor(size/matrix)
|
|
||||||
// and centers, leaving a white margin whenever size isn't divisible.
|
|
||||||
const QR_M_BYTE_CAPACITY = [
|
|
||||||
14, 26, 42, 62, 84, 106, 122, 152, 180, 213,
|
|
||||||
251, 287, 331, 362, 412, 450, 504, 560, 624, 666,
|
|
||||||
711, 779, 857, 911, 997, 1059, 1125, 1190, 1264, 1370,
|
|
||||||
1452, 1538, 1628, 1722, 1809, 1911, 1989, 2099, 2213, 2331,
|
|
||||||
];
|
|
||||||
|
|
||||||
function pickQrMatrixWidth(value) {
|
|
||||||
const byteLen = new TextEncoder().encode(value).length;
|
|
||||||
for (let i = 0; i < QR_M_BYTE_CAPACITY.length; i++) {
|
|
||||||
if (byteLen <= QR_M_BYTE_CAPACITY[i]) return 17 + 4 * (i + 1);
|
|
||||||
}
|
|
||||||
return 17 + 4 * 40; // version 40 (177 modules)
|
|
||||||
}
|
|
||||||
|
|
||||||
function paint() {
|
|
||||||
if (!props.showQr || !canvas.value || !props.value) return;
|
|
||||||
// Canvas size = matrixWidth × pixelSize, so the QR fills it edge-to-
|
|
||||||
// edge. pixelSize is floored against the requested size so the QR
|
|
||||||
// never grows past the host's expected box.
|
|
||||||
const matrixWidth = pickQrMatrixWidth(props.value);
|
|
||||||
const pixelSize = Math.max(1, Math.floor(props.size / matrixWidth));
|
|
||||||
const exactSize = matrixWidth * pixelSize;
|
|
||||||
new QRious({
|
|
||||||
element: canvas.value,
|
|
||||||
size: exactSize,
|
|
||||||
value: props.value,
|
|
||||||
background: 'white',
|
|
||||||
backgroundAlpha: 1,
|
|
||||||
foreground: 'black',
|
|
||||||
padding: 0,
|
|
||||||
level: 'M',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(paint);
|
|
||||||
watch(() => props.value, paint);
|
|
||||||
watch(() => props.size, paint);
|
|
||||||
|
|
||||||
async function copy() {
|
async function copy() {
|
||||||
const ok = await ClipboardManager.copyText(props.value);
|
const ok = await ClipboardManager.copyText(props.value);
|
||||||
if (ok) message.success(t('copied'));
|
if (ok) message.success(t('copied'));
|
||||||
@@ -107,7 +46,8 @@ function download() {
|
|||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showQr" class="qr-panel-canvas">
|
<div v-if="showQr" class="qr-panel-canvas">
|
||||||
<canvas ref="canvas" :style="{ width: `${size}px`, height: `${size}px` }" @click="copy" />
|
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
|
||||||
|
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -140,14 +80,17 @@ function download() {
|
|||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-panel-canvas canvas {
|
.qr-panel-canvas .qr-code {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: block;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
/* Drawing buffer is matrix-snapped (smaller than display size for
|
line-height: 0;
|
||||||
* dense QRs); scale up crisply so dense and sparse QRs share the
|
}
|
||||||
* same on-screen footprint without blurring. */
|
|
||||||
image-rendering: pixelated;
|
.qr-panel-canvas .qr-code :deep(svg) {
|
||||||
image-rendering: crisp-edges;
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-width: 360px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export function useInbounds() {
|
|||||||
const clientCount = ref({});
|
const clientCount = ref({});
|
||||||
const onlineClients = ref([]);
|
const onlineClients = ref([]);
|
||||||
const lastOnlineMap = ref({});
|
const lastOnlineMap = ref({});
|
||||||
|
// Bumps on every client_stats merge so the per-inbound ClientRowTable
|
||||||
|
// child can re-render. DBInbound is a plain class instance, not reactive,
|
||||||
|
// so the in-place mutations on its clientStats array are invisible to
|
||||||
|
// Vue's tracking unless something else (this tick) signals the change.
|
||||||
|
const statsVersion = ref(0);
|
||||||
|
|
||||||
// Default-settings sidecar fields the table needs for color/expiry math.
|
// Default-settings sidecar fields the table needs for color/expiry math.
|
||||||
const expireDiff = ref(0);
|
const expireDiff = ref(0);
|
||||||
@@ -173,9 +178,9 @@ export function useInbounds() {
|
|||||||
rebuildClientCount();
|
rebuildClientCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The client_stats payload carries absolute traffic counters for the
|
// The client_stats payload carries absolute traffic counters for every
|
||||||
// clients that had activity in the latest window plus per-inbound
|
// client + per-inbound totals (full snapshot, not deltas). Both are
|
||||||
// totals. Both are absolute (not deltas), so we overwrite in place.
|
// overwritten in place.
|
||||||
function applyClientStatsEvent(payload) {
|
function applyClientStatsEvent(payload) {
|
||||||
if (!payload || typeof payload !== 'object') return;
|
if (!payload || typeof payload !== 'object') return;
|
||||||
let touched = false;
|
let touched = false;
|
||||||
@@ -220,6 +225,7 @@ export function useInbounds() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (touched) {
|
if (touched) {
|
||||||
|
statsVersion.value++;
|
||||||
dbInbounds.value = [...dbInbounds.value];
|
dbInbounds.value = [...dbInbounds.value];
|
||||||
rebuildClientCount();
|
rebuildClientCount();
|
||||||
}
|
}
|
||||||
@@ -287,6 +293,7 @@ export function useInbounds() {
|
|||||||
const deactive = [];
|
const deactive = [];
|
||||||
const depleted = [];
|
const depleted = [];
|
||||||
const expiring = [];
|
const expiring = [];
|
||||||
|
const online = [];
|
||||||
for (const ib of dbInbounds.value) {
|
for (const ib of dbInbounds.value) {
|
||||||
up += ib.up || 0;
|
up += ib.up || 0;
|
||||||
down += ib.down || 0;
|
down += ib.down || 0;
|
||||||
@@ -297,9 +304,10 @@ export function useInbounds() {
|
|||||||
deactive.push(...c.deactive);
|
deactive.push(...c.deactive);
|
||||||
depleted.push(...c.depleted);
|
depleted.push(...c.depleted);
|
||||||
expiring.push(...c.expiring);
|
expiring.push(...c.expiring);
|
||||||
|
online.push(...c.online);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { up, down, allTime, clients, deactive, depleted, expiring };
|
return { up, down, allTime, clients, deactive, depleted, expiring, online };
|
||||||
});
|
});
|
||||||
|
|
||||||
// ObjectUtil reference is wired at module load — keeping a no-op import
|
// ObjectUtil reference is wired at module load — keeping a no-op import
|
||||||
@@ -313,6 +321,7 @@ export function useInbounds() {
|
|||||||
clientCount,
|
clientCount,
|
||||||
onlineClients,
|
onlineClients,
|
||||||
lastOnlineMap,
|
lastOnlineMap,
|
||||||
|
statsVersion,
|
||||||
totals,
|
totals,
|
||||||
expireDiff,
|
expireDiff,
|
||||||
trafficDiff,
|
trafficDiff,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function exportDb() {
|
|||||||
// The Go endpoint streams x-ui.db as a download. Setting
|
// The Go endpoint streams x-ui.db as a download. Setting
|
||||||
// window.location triggers a browser download without leaving
|
// window.location triggers a browser download without leaving
|
||||||
// the page (the Go side responds with Content-Disposition: attachment).
|
// the page (the Go side responds with Content-Disposition: attachment).
|
||||||
window.location = '/panel/api/server/getDb';
|
window.location = window.X_UI_BASE_PATH+'panel/api/server/getDb';
|
||||||
}
|
}
|
||||||
|
|
||||||
function importDb() {
|
function importDb() {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
SwapOutlined,
|
SwapOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
EyeInvisibleOutlined,
|
EyeInvisibleOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
DesktopOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
|
ForkOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -31,6 +35,7 @@ import PanelUpdateModal from './PanelUpdateModal.vue';
|
|||||||
import LogModal from './LogModal.vue';
|
import LogModal from './LogModal.vue';
|
||||||
import BackupModal from './BackupModal.vue';
|
import BackupModal from './BackupModal.vue';
|
||||||
import SystemHistoryModal from './SystemHistoryModal.vue';
|
import SystemHistoryModal from './SystemHistoryModal.vue';
|
||||||
|
import XrayMetricsModal from './XrayMetricsModal.vue';
|
||||||
import XrayLogModal from './XrayLogModal.vue';
|
import XrayLogModal from './XrayLogModal.vue';
|
||||||
import VersionModal from './VersionModal.vue';
|
import VersionModal from './VersionModal.vue';
|
||||||
|
|
||||||
@@ -53,14 +58,14 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
const requestUri = window.location.pathname;
|
const requestUri = window.location.pathname;
|
||||||
|
|
||||||
// In production, dist.go injects window.__X_UI_CUR_VER__ at serve time.
|
// 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
|
// In dev, Vite serves the HTML directly so the global is missing — fall
|
||||||
// back to currentVersion from the panel-update API once it answers.
|
// back to currentVersion from the panel-update API once it answers.
|
||||||
const displayVersion = computed(
|
const displayVersion = computed(
|
||||||
() => panelUpdateInfo.value?.currentVersion || window.__X_UI_CUR_VER__ || '?',
|
() => panelUpdateInfo.value?.currentVersion || window.X_UI_CUR_VER || '?',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hide/reveal the public IPv4/IPv6 — same pattern as legacy.
|
// Hide/reveal the public IPv4/IPv6 — same pattern as legacy.
|
||||||
@@ -71,6 +76,7 @@ const logsOpen = ref(false);
|
|||||||
const backupOpen = ref(false);
|
const backupOpen = ref(false);
|
||||||
const panelUpdateOpen = ref(false);
|
const panelUpdateOpen = ref(false);
|
||||||
const sysHistoryOpen = ref(false);
|
const sysHistoryOpen = ref(false);
|
||||||
|
const xrayMetricsOpen = ref(false);
|
||||||
const xrayLogsOpen = ref(false);
|
const xrayLogsOpen = ref(false);
|
||||||
const versionOpen = ref(false);
|
const versionOpen = ref(false);
|
||||||
const configTextOpen = ref(false);
|
const configTextOpen = ref(false);
|
||||||
@@ -98,6 +104,18 @@ function openSystemHistory() { sysHistoryOpen.value = true; }
|
|||||||
function openXrayLogs() { xrayLogsOpen.value = true; }
|
function openXrayLogs() { xrayLogsOpen.value = true; }
|
||||||
function openVersionSwitch() { versionOpen.value = true; }
|
function openVersionSwitch() { versionOpen.value = true; }
|
||||||
|
|
||||||
|
function openPanelVersion() {
|
||||||
|
if (panelUpdateInfo.value.updateAvailable) {
|
||||||
|
panelUpdateOpen.value = true;
|
||||||
|
} else {
|
||||||
|
window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTelegram() {
|
||||||
|
window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy "Config" action — fetch the rendered xray config and show
|
// Legacy "Config" action — fetch the rendered xray config and show
|
||||||
// it as JSON in the shared TextModal (same UX as main).
|
// it as JSON in the shared TextModal (same UX as main).
|
||||||
async function openConfig() {
|
async function openConfig() {
|
||||||
@@ -155,62 +173,83 @@ async function openConfig() {
|
|||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
<a-col :xs="24" :lg="12">
|
||||||
<a-card title="3X-UI" hoverable>
|
<a-card title="3X-UI" hoverable>
|
||||||
<template v-if="panelUpdateInfo.updateAvailable" #extra>
|
<template #actions>
|
||||||
<a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`">
|
<a-space class="action" @click="openTelegram">
|
||||||
<a-tag color="orange" class="update-tag" @click="panelUpdateOpen = true">
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" class="tg-icon"
|
||||||
<CloudDownloadOutlined />
|
aria-hidden="true">
|
||||||
{{ panelUpdateInfo.latestVersion }}
|
<path
|
||||||
<span v-if="!isMobile">{{ t('pages.index.updatePanel') }}</span>
|
d="M21.93 4.34a1.5 1.5 0 0 0-2.05-1.6L2.97 9.6c-.92.36-.91 1.66.02 1.99l4.32 1.53 1.7 5.23a1 1 0 0 0 1.68.36l2.43-2.43 4.36 3.21a1.5 1.5 0 0 0 2.36-.91l3.09-13.86a1.5 1.5 0 0 0 0-.38ZM9.97 14.66l-.55 3.36-1.36-4.2 9.8-7.05-7.89 7.89Z" />
|
||||||
</a-tag>
|
</svg>
|
||||||
</a-tooltip>
|
<span v-if="!isMobile">@XrayUI</span>
|
||||||
|
</a-space>
|
||||||
|
<a-space class="action" :class="{ 'action-update': panelUpdateInfo.updateAvailable }"
|
||||||
|
@click="openPanelVersion">
|
||||||
|
<CloudDownloadOutlined />
|
||||||
|
<span v-if="!isMobile">
|
||||||
|
{{ panelUpdateInfo.updateAvailable
|
||||||
|
? `${t('update')} ${panelUpdateInfo.latestVersion}`
|
||||||
|
: `v${displayVersion}` }}
|
||||||
|
</span>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-card :title="t('pages.index.charts')" hoverable>
|
||||||
|
<template #actions>
|
||||||
|
<a-space class="action" @click="openSystemHistory">
|
||||||
|
<AreaChartOutlined />
|
||||||
|
<span v-if="!isMobile">{{ t('pages.index.systemHistoryTitle') }}</span>
|
||||||
|
</a-space>
|
||||||
|
<a-space class="action" @click="xrayMetricsOpen = true">
|
||||||
|
<AreaChartOutlined />
|
||||||
|
<span v-if="!isMobile">{{ t('pages.index.xrayMetricsTitle') }}</span>
|
||||||
|
</a-space>
|
||||||
</template>
|
</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-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
<a-col :xs="24" :lg="12">
|
||||||
<a-card :title="t('pages.index.operationHours')" hoverable>
|
<a-card :title="t('pages.index.operationHours')" hoverable>
|
||||||
<a-tag :color="status.xray.color">
|
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||||
Xray: {{ TimeFormatter.formatSecond(status.appStats.uptime) }}
|
<a-col :span="12">
|
||||||
</a-tag>
|
<CustomStatistic title="Xray" :value="TimeFormatter.formatSecond(status.appStats.uptime)">
|
||||||
<a-tag color="green">OS: {{ TimeFormatter.formatSecond(status.uptime) }}</a-tag>
|
<template #prefix>
|
||||||
</a-card>
|
<ThunderboltOutlined />
|
||||||
</a-col>
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
<a-col :xs="24" :lg="12">
|
</a-col>
|
||||||
<a-card :title="t('pages.index.systemLoad')" hoverable>
|
<a-col :span="12">
|
||||||
<template #extra>
|
<CustomStatistic title="OS" :value="TimeFormatter.formatSecond(status.uptime)">
|
||||||
<a-tag color="blue" class="history-tag" @click="openSystemHistory">
|
<template #prefix>
|
||||||
<AreaChartOutlined />
|
<DesktopOutlined />
|
||||||
{{ t('pages.index.systemHistoryTitle') }}
|
</template>
|
||||||
</a-tag>
|
</CustomStatistic>
|
||||||
</template>
|
</a-col>
|
||||||
<a-tooltip :title="t('pages.index.systemLoadDesc')">
|
</a-row>
|
||||||
<a-tag color="green">
|
|
||||||
{{ status.loads[0] }} | {{ status.loads[1] }} | {{ status.loads[2] }}
|
|
||||||
</a-tag>
|
|
||||||
</a-tooltip>
|
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
<a-col :xs="24" :lg="12">
|
||||||
<a-card :title="t('usage')" hoverable>
|
<a-card :title="t('usage')" hoverable>
|
||||||
<a-tag color="green">
|
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||||
{{ t('pages.index.memory') }}: {{ SizeFormatter.sizeFormat(status.appStats.mem) }}
|
<a-col :span="12">
|
||||||
</a-tag>
|
<CustomStatistic :title="t('pages.index.memory')"
|
||||||
<a-tag color="green">
|
:value="SizeFormatter.sizeFormat(status.appStats.mem)">
|
||||||
{{ t('pages.index.threads') }}: {{ status.appStats.threads }}
|
<template #prefix>
|
||||||
</a-tag>
|
<DatabaseOutlined />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<CustomStatistic :title="t('pages.index.threads')" :value="status.appStats.threads">
|
||||||
|
<template #prefix>
|
||||||
|
<ForkOutlined />
|
||||||
|
</template>
|
||||||
|
</CustomStatistic>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
@@ -318,6 +357,7 @@ async function openConfig() {
|
|||||||
<LogModal v-model:open="logsOpen" />
|
<LogModal v-model:open="logsOpen" />
|
||||||
<BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
|
<BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
|
||||||
<SystemHistoryModal v-model:open="sysHistoryOpen" :status="status" />
|
<SystemHistoryModal v-model:open="sysHistoryOpen" :status="status" />
|
||||||
|
<XrayMetricsModal v-model:open="xrayMetricsOpen" />
|
||||||
<XrayLogModal v-model:open="xrayLogsOpen" />
|
<XrayLogModal v-model:open="xrayLogsOpen" />
|
||||||
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
|
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
|
||||||
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
|
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
|
||||||
@@ -336,8 +376,8 @@ async function openConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.index-page.is-dark {
|
.index-page.is-dark {
|
||||||
--bg-page: #0a1222;
|
--bg-page: #1e1e1e;
|
||||||
--bg-card: #151f31;
|
--bg-card: #252526;
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page.is-dark.is-ultra {
|
.index-page.is-dark.is-ultra {
|
||||||
@@ -374,12 +414,13 @@ async function openConfig() {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-tag {
|
.action-update {
|
||||||
cursor: pointer;
|
color: #fa8c16;
|
||||||
margin: 0;
|
font-weight: 600;
|
||||||
display: inline-flex;
|
}
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
.action-update :deep(.anticon) {
|
||||||
|
color: #fa8c16;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-tag {
|
.history-tag {
|
||||||
@@ -390,18 +431,9 @@ async function openConfig() {
|
|||||||
margin-inline-end: 0;
|
margin-inline-end: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-tags {
|
.tg-icon {
|
||||||
display: flex;
|
display: inline-block;
|
||||||
flex-wrap: wrap;
|
vertical-align: -2px;
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-tags a {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-tags :deep(.ant-tag) {
|
|
||||||
margin-inline-end: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ip-toggle-icon {
|
.ip-toggle-icon {
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ function parseLogLine(line) {
|
|||||||
service = 'X-UI:';
|
service = 'X-UI:';
|
||||||
}
|
}
|
||||||
|
|
||||||
return { date, time, levelText, levelClass, service, body };
|
const stamp = [date, time].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return { date, time, stamp, levelText, levelClass, service, body };
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedLogs = computed(() => logs.value.map(parseLogLine));
|
const parsedLogs = computed(() => logs.value.map(parseLogLine));
|
||||||
@@ -133,33 +135,25 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
|
|||||||
<template v-else-if="isMobile">
|
<template v-else-if="isMobile">
|
||||||
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-card">
|
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-card">
|
||||||
<div class="log-card-head">
|
<div class="log-card-head">
|
||||||
<span v-if="log.date || log.time" class="log-time">
|
<span v-if="log.stamp" class="log-time">
|
||||||
<span v-if="log.time">{{ log.time }}</span>
|
<span v-if="log.time">{{ log.time }}</span>{{ log.time && log.date ? ' ' : '' }}<span v-if="log.date" class="log-date">{{ log.date }}</span>
|
||||||
<span v-if="log.date" class="log-date">{{ log.date }}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span v-if="log.levelText" class="log-level-badge" :class="log.levelClass">
|
<span v-if="log.levelText" class="log-level-badge" :class="log.levelClass">
|
||||||
{{ log.levelText }}
|
{{ log.levelText }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="log.body || log.service" class="log-body">
|
<div v-if="log.body || log.service" class="log-body">
|
||||||
<b v-if="log.service">{{ log.service }}</b>
|
<b v-if="log.service">{{ log.service }}</b>{{ log.service && log.body ? ' ' : '' }}<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
|
||||||
<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-line">
|
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-line">
|
||||||
<span v-if="log.date || log.time" class="log-stamp">
|
<span v-if="log.stamp" class="log-stamp">{{ log.stamp }}</span>{{ log.stamp && log.levelText ? ' ' : '' }}<span v-if="log.levelText" class="log-level" :class="log.levelClass">{{ log.levelText }}</span>
|
||||||
{{ 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">
|
<template v-if="log.body || log.service">
|
||||||
<span> - </span>
|
<span> - </span>
|
||||||
<b v-if="log.service">{{ log.service }} </b>
|
<b v-if="log.service">{{ log.service }}</b>{{ log.service && log.body ? ' ' : '' }}<span>{{ log.body }}</span>
|
||||||
<span>{{ log.body }}</span>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
<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 },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['update:open']);
|
||||||
|
|
||||||
|
const OBS_KEY = 'xrObs';
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
{ key: 'xrAlloc', tab: 'Heap', unit: 'B', stroke: '#7c4dff' },
|
||||||
|
{ key: 'xrSys', tab: 'Sys', unit: 'B', stroke: '#1890ff' },
|
||||||
|
{ key: 'xrHeapObjects', tab: 'Objects', unit: '', stroke: '#13c2c2' },
|
||||||
|
{ key: 'xrNumGC', tab: 'GC Count', unit: '', stroke: '#fa8c16' },
|
||||||
|
{ key: 'xrPauseNs', tab: 'GC Pause', unit: 'ns', stroke: '#f5222d' },
|
||||||
|
{ key: OBS_KEY, tab: 'Observatory', unit: 'ms', stroke: '#52c41a' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeKey = ref('xrAlloc');
|
||||||
|
const bucket = ref(2);
|
||||||
|
const points = ref([]);
|
||||||
|
const labels = ref([]);
|
||||||
|
const state = ref({ enabled: false, listen: '', reason: '' });
|
||||||
|
const obsTags = ref([]);
|
||||||
|
const obsActiveTag = ref('');
|
||||||
|
let obsTimer = null;
|
||||||
|
|
||||||
|
const activeMetric = computed(() => metrics.find((m) => m.key === activeKey.value));
|
||||||
|
const isObservatory = computed(() => activeKey.value === OBS_KEY);
|
||||||
|
const strokeColor = computed(() => activeMetric.value?.stroke || '#008771');
|
||||||
|
const activeObsTag = computed(() => obsTags.value.find((tg) => tg.tag === obsActiveTag.value) || null);
|
||||||
|
|
||||||
|
function unitFormatter(unit) {
|
||||||
|
if (unit === 'B') {
|
||||||
|
return (v) => SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0));
|
||||||
|
}
|
||||||
|
if (unit === 'ns') {
|
||||||
|
return (v) => {
|
||||||
|
const n = Math.max(0, Number(v) || 0);
|
||||||
|
if (n >= 1e6) return `${(n / 1e6).toFixed(2)} ms`;
|
||||||
|
if (n >= 1e3) return `${(n / 1e3).toFixed(1)} µs`;
|
||||||
|
return `${n.toFixed(0)} ns`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (unit === 'ms') {
|
||||||
|
return (v) => `${Math.round(Number(v) || 0)} ms`;
|
||||||
|
}
|
||||||
|
return (v) => {
|
||||||
|
const n = Number(v) || 0;
|
||||||
|
return Math.round(n).toLocaleString();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const yFormatter = computed(() => unitFormatter(activeMetric.value?.unit ?? ''));
|
||||||
|
|
||||||
|
function fmtTimestamp(unixSec) {
|
||||||
|
if (!unixSec) return '—';
|
||||||
|
const d = new Date(unixSec * 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');
|
||||||
|
return `${d.toLocaleDateString()} ${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchState() {
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/server/xrayMetricsState');
|
||||||
|
if (msg?.success && msg.obj) state.value = msg.obj;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch xray metrics state', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchObservatory() {
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/server/xrayObservatory');
|
||||||
|
if (msg?.success && Array.isArray(msg.obj)) {
|
||||||
|
obsTags.value = msg.obj;
|
||||||
|
if (!obsTags.value.find((tg) => tg.tag === obsActiveTag.value)) {
|
||||||
|
obsActiveTag.value = obsTags.value[0]?.tag || '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
obsTags.value = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch observatory snapshot', e);
|
||||||
|
obsTags.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMetricBucket() {
|
||||||
|
const m = activeMetric.value;
|
||||||
|
if (!m) return;
|
||||||
|
try {
|
||||||
|
const url = `/panel/api/server/xrayMetricsHistory/${m.key}/${bucket.value}`;
|
||||||
|
const msg = await HttpUtil.get(url);
|
||||||
|
applyHistory(msg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch xray metrics bucket', e);
|
||||||
|
labels.value = [];
|
||||||
|
points.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchObsBucket() {
|
||||||
|
const tag = obsActiveTag.value;
|
||||||
|
if (!tag) {
|
||||||
|
labels.value = [];
|
||||||
|
points.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(tag)}/${bucket.value}`;
|
||||||
|
const msg = await HttpUtil.get(url);
|
||||||
|
applyHistory(msg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch observatory bucket', e);
|
||||||
|
labels.value = [];
|
||||||
|
points.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHistory(msg) {
|
||||||
|
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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshActive() {
|
||||||
|
if (isObservatory.value) {
|
||||||
|
fetchObsBucket();
|
||||||
|
} else {
|
||||||
|
fetchMetricBucket();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startObsPolling() {
|
||||||
|
stopObsPolling();
|
||||||
|
obsTimer = window.setInterval(async () => {
|
||||||
|
if (!props.open || !isObservatory.value) return;
|
||||||
|
await fetchObservatory();
|
||||||
|
fetchObsBucket();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopObsPolling() {
|
||||||
|
if (obsTimer != null) {
|
||||||
|
window.clearInterval(obsTimer);
|
||||||
|
obsTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (next) {
|
||||||
|
activeKey.value = 'xrAlloc';
|
||||||
|
fetchState();
|
||||||
|
fetchMetricBucket();
|
||||||
|
} else {
|
||||||
|
stopObsPolling();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(activeKey, async (key) => {
|
||||||
|
if (!props.open) return;
|
||||||
|
if (key === OBS_KEY) {
|
||||||
|
await fetchObservatory();
|
||||||
|
fetchObsBucket();
|
||||||
|
startObsPolling();
|
||||||
|
} else {
|
||||||
|
stopObsPolling();
|
||||||
|
fetchMetricBucket();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(bucket, () => {
|
||||||
|
if (props.open) refreshActive();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(obsActiveTag, () => {
|
||||||
|
if (props.open && isObservatory.value) fetchObsBucket();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" @cancel="close">
|
||||||
|
<template #title>
|
||||||
|
{{ t('pages.index.xrayMetricsTitle') }}
|
||||||
|
<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-alert v-if="!state.enabled" type="warning" show-icon class="metrics-alert"
|
||||||
|
:message="t('pages.index.xrayMetricsDisabled')"
|
||||||
|
:description="state.reason || t('pages.index.xrayMetricsHint')" />
|
||||||
|
|
||||||
|
<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 v-if="isObservatory" class="obs-pane">
|
||||||
|
<a-alert v-if="state.enabled && obsTags.length === 0" type="info" show-icon class="metrics-alert"
|
||||||
|
:message="t('pages.index.xrayObservatoryEmpty')"
|
||||||
|
:description="t('pages.index.xrayObservatoryHint')" />
|
||||||
|
|
||||||
|
<div v-else class="obs-controls">
|
||||||
|
<a-select v-model:value="obsActiveTag" size="small" class="obs-select"
|
||||||
|
:placeholder="t('pages.index.xrayObservatoryTagPlaceholder')">
|
||||||
|
<a-select-option v-for="tg in obsTags" :key="tg.tag" :value="tg.tag">
|
||||||
|
<span class="obs-dot" :class="tg.alive ? 'is-alive' : 'is-dead'" />
|
||||||
|
{{ tg.tag }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<div v-if="activeObsTag" class="obs-stats">
|
||||||
|
<a-tag :color="activeObsTag.alive ? 'green' : 'red'">
|
||||||
|
{{ activeObsTag.alive ? t('pages.index.xrayObservatoryAlive') : t('pages.index.xrayObservatoryDead') }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag color="blue">{{ activeObsTag.delay }} ms</a-tag>
|
||||||
|
<span class="obs-stamp">
|
||||||
|
{{ t('pages.index.xrayObservatoryLastSeen') }}: {{ fmtTimestamp(activeObsTag.lastSeenTime) }}
|
||||||
|
</span>
|
||||||
|
<span class="obs-stamp">
|
||||||
|
{{ t('pages.index.xrayObservatoryLastTry') }}: {{ fmtTimestamp(activeObsTag.lastTryTime) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cpu-chart-wrap">
|
||||||
|
<div class="cpu-chart-meta">
|
||||||
|
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
|
||||||
|
<span v-if="state.enabled && state.listen" class="listen-tag"> · {{ state.listen }}</span>
|
||||||
|
</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="null" :y-formatter="yFormatter" />
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bucket-select {
|
||||||
|
width: 80px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-alert {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tabs {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-pane {
|
||||||
|
padding: 4px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-select {
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-stamp {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-dot.is-alive {
|
||||||
|
background: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-dot.is-dead {
|
||||||
|
background: #f5222d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpu-chart-wrap {
|
||||||
|
padding: 8px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpu-chart-meta {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listen-tag {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,11 +8,25 @@ import {
|
|||||||
antdThemeConfig,
|
antdThemeConfig,
|
||||||
currentTheme,
|
currentTheme,
|
||||||
theme as themeState,
|
theme as themeState,
|
||||||
|
toggleTheme,
|
||||||
|
toggleUltra,
|
||||||
|
pauseAnimationsUntilLeave,
|
||||||
} from '@/composables/useTheme.js';
|
} from '@/composables/useTheme.js';
|
||||||
import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
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 headlineWords = computed(() => [t('pages.login.hello'), t('pages.login.title')]);
|
||||||
const HEADLINE_INTERVAL_MS = 2000;
|
const HEADLINE_INTERVAL_MS = 2000;
|
||||||
const headlineIndex = ref(0);
|
const headlineIndex = ref(0);
|
||||||
@@ -28,23 +42,9 @@ onBeforeUnmount(() => {
|
|||||||
if (headlineTimer != null) window.clearInterval(headlineTimer);
|
if (headlineTimer != null) window.clearInterval(headlineTimer);
|
||||||
});
|
});
|
||||||
|
|
||||||
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__ || '';
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const msg = await HttpUtil.post('/getTwoFactorEnable');
|
const msg = await HttpUtil.post('/getTwoFactorEnable');
|
||||||
if (msg.success) {
|
if (msg.success) twoFactorEnable.value = !!msg.obj;
|
||||||
twoFactorEnable.value = !!msg.obj;
|
|
||||||
}
|
|
||||||
fetched.value = true;
|
fetched.value = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,9 +52,7 @@ async function login() {
|
|||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/login', user);
|
const msg = await HttpUtil.post('/login', user);
|
||||||
if (msg.success) {
|
if (msg.success) window.location.href = basePath + 'panel/';
|
||||||
window.location.href = basePath + 'panel/';
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
@@ -64,108 +62,123 @@ const lang = ref(LanguageManager.getLanguage());
|
|||||||
function onLangChange(next) {
|
function onLangChange(next) {
|
||||||
LanguageManager.setLanguage(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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a-config-provider :theme="antdThemeConfig">
|
<a-config-provider :theme="antdThemeConfig">
|
||||||
<a-layout class="login-app" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
<a-layout class="login-app" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||||
<a-layout-content class="login-content">
|
<a-layout-content class="login-content">
|
||||||
<div class="waves-header">
|
<!-- Floating chrome at top-right: theme cycle (Light/Dark/Ultra)
|
||||||
<div class="waves-inner-header"></div>
|
plus a language picker hidden behind the gear popover. -->
|
||||||
<svg class="waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
<div class="login-toolbar">
|
||||||
viewBox="0 24 150 28" preserveAspectRatio="none" shape-rendering="auto">
|
<button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
|
||||||
<defs>
|
@click="cycleTheme">
|
||||||
<path id="gentle-wave" d="M-160 44c30 0 58-18 88-18s 58 18 88 18 58-18 88-18 58 18 88 18 v44h-352z" />
|
<svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
</defs>
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<g class="parallax">
|
<circle cx="12" cy="12" r="4" />
|
||||||
<use xlink:href="#gentle-wave" x="48" y="0" />
|
<path
|
||||||
<use xlink:href="#gentle-wave" x="48" y="3" />
|
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" />
|
||||||
<use xlink:href="#gentle-wave" x="48" y="5" />
|
</svg>
|
||||||
<use xlink:href="#gentle-wave" x="48" y="7" />
|
<svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
</g>
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
</svg>
|
<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>
|
||||||
|
|
||||||
<a-row type="flex" justify="center" align="middle" class="login-row">
|
<div class="login-wrapper">
|
||||||
<a-col class="login-card">
|
<div v-if="!fetched" class="login-loading">
|
||||||
<div v-if="!fetched" class="login-loading">
|
<a-spin size="large" />
|
||||||
<a-spin size="large" />
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else>
|
<div v-else class="login-card">
|
||||||
<div class="login-settings">
|
<div class="brand">
|
||||||
<a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight"
|
<span class="brand-name">3X-UI</span>
|
||||||
trigger="click">
|
<span class="brand-accent" aria-hidden="true"></span>
|
||||||
<template #content>
|
</div>
|
||||||
<a-space direction="vertical" :size="10" class="settings-popover">
|
<h2 class="welcome">
|
||||||
<ThemeSwitchLogin />
|
<Transition name="headline" mode="out-in">
|
||||||
<span>{{ t('pages.settings.language') }}</span>
|
<b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
|
||||||
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
|
</Transition>
|
||||||
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
|
</h2>
|
||||||
:value="l.value">
|
|
||||||
<span :aria-label="l.name">{{ l.icon }}</span>
|
<a-form layout="vertical" class="login-form" @submit.prevent="login">
|
||||||
<span>{{ l.name }}</span>
|
<a-form-item :label="t('username')">
|
||||||
</a-select-option>
|
<a-input v-model:value="user.username" autocomplete="username" name="username" size="large"
|
||||||
</a-select>
|
:placeholder="t('username')" autofocus required>
|
||||||
</a-space>
|
<template #prefix>
|
||||||
|
<UserOutlined />
|
||||||
</template>
|
</template>
|
||||||
<a-button shape="circle">
|
</a-input>
|
||||||
<template #icon>
|
</a-form-item>
|
||||||
<SettingOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a-row justify="center">
|
<a-form-item :label="t('password')">
|
||||||
<a-col :span="24">
|
<a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
|
||||||
<h2 class="login-title">
|
size="large" :placeholder="t('password')" required>
|
||||||
<Transition name="headline" mode="out-in">
|
<template #prefix>
|
||||||
<b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
|
<LockOutlined />
|
||||||
</Transition>
|
</template>
|
||||||
</h2>
|
</a-input-password>
|
||||||
</a-col>
|
</a-form-item>
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<a-form layout="vertical" @submit.prevent="login">
|
<a-form-item v-if="twoFactorEnable" :label="t('twoFactorCode')">
|
||||||
<a-form-item>
|
<a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
|
||||||
<a-input v-model:value="user.username" autocomplete="username" name="username"
|
size="large" :placeholder="t('twoFactorCode')" required>
|
||||||
:placeholder="t('username')" autofocus required>
|
<template #prefix>
|
||||||
<template #prefix>
|
<KeyOutlined />
|
||||||
<UserOutlined />
|
</template>
|
||||||
</template>
|
</a-input>
|
||||||
</a-input>
|
</a-form-item>
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item>
|
<a-form-item class="submit-row">
|
||||||
<a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
|
<a-button type="primary" html-type="submit" :loading="submitting" size="large" block>
|
||||||
:placeholder="t('password')" required>
|
{{ submitting ? '' : t('login') }}
|
||||||
<template #prefix>
|
</a-button>
|
||||||
<LockOutlined />
|
</a-form-item>
|
||||||
</template>
|
</a-form>
|
||||||
</a-input-password>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="twoFactorEnable">
|
</div>
|
||||||
<a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
|
</div>
|
||||||
:placeholder="t('twoFactorCode')" required>
|
|
||||||
<template #prefix>
|
|
||||||
<KeyOutlined />
|
|
||||||
</template>
|
|
||||||
</a-input>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item>
|
|
||||||
<a-row justify="center">
|
|
||||||
<a-button type="primary" html-type="submit" :loading="submitting" block>
|
|
||||||
{{ submitting ? '' : t('login') }}
|
|
||||||
</a-button>
|
|
||||||
</a-row>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</div>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-layout-content>
|
</a-layout-content>
|
||||||
</a-layout>
|
</a-layout>
|
||||||
</a-config-provider>
|
</a-config-provider>
|
||||||
@@ -173,59 +186,293 @@ function onLangChange(next) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.login-app {
|
.login-app {
|
||||||
--bg-page: #c7ebe2;
|
--bg-page: #f5f7fa;
|
||||||
--bg-wave-header: #dbf5ed;
|
|
||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
--color-title: #008771;
|
--color-text: rgba(0, 0, 0, 0.88);
|
||||||
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.09);
|
--color-text-subtle: rgba(0, 0, 0, 0.55);
|
||||||
--wave-fill: rgba(0, 135, 113, 0.12);
|
--color-accent: #1677ff;
|
||||||
--wave-fill-bottom: #c7ebe2;
|
--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;
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-app.is-dark {
|
.login-app.is-dark {
|
||||||
--bg-page: #222d42;
|
--bg-page: #1e1e1e;
|
||||||
--bg-wave-header: #0a1222;
|
--bg-card: #252526;
|
||||||
--bg-card: #151f31;
|
--color-text: rgba(255, 255, 255, 0.92);
|
||||||
--color-title: rgba(255, 255, 255, 0.92);
|
--color-text-subtle: rgba(255, 255, 255, 0.55);
|
||||||
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.45);
|
--color-accent: #4096ff;
|
||||||
--wave-fill: #222d42;
|
--color-border: rgba(255, 255, 255, 0.08);
|
||||||
--wave-fill-bottom: #222d42;
|
--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 {
|
.login-app.is-dark.is-ultra {
|
||||||
--bg-page: #0f2d32;
|
--bg-page: #000;
|
||||||
--bg-wave-header: #0a2227;
|
--bg-card: #141414;
|
||||||
--bg-card: #0c0e12;
|
--color-border: rgba(255, 255, 255, 0.06);
|
||||||
--wave-fill: #1f4d52;
|
--blob-1: rgba(64, 150, 255, 0.22);
|
||||||
--wave-fill-bottom: #0f2d32;
|
--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,
|
|
||||||
.login-app :deep(.ant-layout-content) {
|
.login-app :deep(.ant-layout-content) {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-app {
|
.login-content {
|
||||||
background: var(--bg-page);
|
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 {
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px 32px 28px;
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-title {
|
@media (max-width: 480px) {
|
||||||
color: var(--color-title);
|
.login-card {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-settings {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
margin-bottom: 8px;
|
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 {
|
.settings-popover {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
@@ -233,118 +480,4 @@ function onLangChange(next) {
|
|||||||
.lang-select {
|
.lang-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-content {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-row {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 24px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
width: clamp(280px, 90vw, 300px);
|
|
||||||
border-radius: 2rem;
|
|
||||||
padding: clamp(2rem, 5vw, 4rem) 1.5rem;
|
|
||||||
transition: background 0.3s, box-shadow 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-title {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 500;
|
|
||||||
min-height: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-title b {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headline-enter-active,
|
|
||||||
.headline-leave-active {
|
|
||||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headline-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.headline-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.waves-header {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0 0 auto 0;
|
|
||||||
width: 100%;
|
|
||||||
z-index: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
background: var(--bg-wave-header);
|
|
||||||
}
|
|
||||||
|
|
||||||
.waves-inner-header {
|
|
||||||
height: 50vh;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.waves {
|
|
||||||
position: relative;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 15vh;
|
|
||||||
min-height: 100px;
|
|
||||||
max-height: 150px;
|
|
||||||
margin-bottom: -8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parallax>use {
|
|
||||||
fill: var(--wave-fill);
|
|
||||||
animation: move-forever 25s cubic-bezier(0.55, 0.5, 0.45, 0.5) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parallax>use:nth-child(1) {
|
|
||||||
animation-delay: -2s;
|
|
||||||
animation-duration: 4s;
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parallax>use:nth-child(2) {
|
|
||||||
animation-delay: -3s;
|
|
||||||
animation-duration: 7s;
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parallax>use:nth-child(3) {
|
|
||||||
animation-delay: -4s;
|
|
||||||
animation-duration: 10s;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parallax>use:nth-child(4) {
|
|
||||||
animation-delay: -5s;
|
|
||||||
animation-duration: 13s;
|
|
||||||
fill: var(--wave-fill-bottom);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes move-forever {
|
|
||||||
0% {
|
|
||||||
transform: translate3d(-90px, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: translate3d(85px, 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ function defaultForm() {
|
|||||||
basePath: '/',
|
basePath: '/',
|
||||||
apiToken: '',
|
apiToken: '',
|
||||||
enable: true,
|
enable: true,
|
||||||
|
allowPrivateAddress: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ function buildPayload() {
|
|||||||
basePath: form.basePath?.trim() || '/',
|
basePath: form.basePath?.trim() || '/',
|
||||||
apiToken: form.apiToken?.trim() || '',
|
apiToken: form.apiToken?.trim() || '',
|
||||||
enable: !!form.enable,
|
enable: !!form.enable,
|
||||||
|
allowPrivateAddress: !!form.allowPrivateAddress,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +163,11 @@ async function onSave() {
|
|||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item label="Allow private address">
|
||||||
|
<a-switch v-model:checked="form.allowPrivateAddress" />
|
||||||
|
<div class="hint">Enable only for nodes on a private network or VPN.</div>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item :label="t('pages.nodes.apiToken')" required>
|
<a-form-item :label="t('pages.nodes.apiToken')" required>
|
||||||
<a-input-password v-model:value="form.apiToken" :placeholder="t('pages.nodes.apiTokenPlaceholder')" />
|
<a-input-password v-model:value="form.apiToken" :placeholder="t('pages.nodes.apiTokenPlaceholder')" />
|
||||||
<div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>
|
<div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
import {
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
EyeInvisibleOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
RightOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import NodeHistoryPanel from './NodeHistoryPanel.vue';
|
import NodeHistoryPanel from './NodeHistoryPanel.vue';
|
||||||
|
|
||||||
@@ -26,8 +31,6 @@ const emit = defineEmits([
|
|||||||
|
|
||||||
const { t } = useI18n();
|
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(() =>
|
const dataSource = computed(() =>
|
||||||
props.nodes.map((n) => ({
|
props.nodes.map((n) => ({
|
||||||
...n,
|
...n,
|
||||||
@@ -36,6 +39,8 @@ const dataSource = computed(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showAddress = ref(false);
|
||||||
|
|
||||||
function statusColor(status) {
|
function statusColor(status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'online': return 'green';
|
case 'online': return 'green';
|
||||||
@@ -70,6 +75,25 @@ function formatPct(p) {
|
|||||||
if (typeof p !== 'number' || isNaN(p)) return '-';
|
if (typeof p !== 'number' || isNaN(p)) return '-';
|
||||||
return `${p.toFixed(1)}%`;
|
return `${p.toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statsNode = ref(null);
|
||||||
|
function openStats(node) {
|
||||||
|
statsNode.value = node;
|
||||||
|
}
|
||||||
|
function closeStats() {
|
||||||
|
statsNode.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -83,7 +107,103 @@ function formatPct(p) {
|
|||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-table :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
|
<!-- ====================== Mobile: card list ======================= -->
|
||||||
|
<div v-if="isMobile" class="node-cards">
|
||||||
|
<div v-if="dataSource.length === 0" class="card-empty">—</div>
|
||||||
|
|
||||||
|
<div v-for="record in dataSource" :key="record.id" class="node-card">
|
||||||
|
<div class="card-head" @click="toggleExpanded(record.id)">
|
||||||
|
<RightOutlined class="card-expand" :class="{ 'is-expanded': isExpanded(record.id) }" />
|
||||||
|
<a-badge
|
||||||
|
:status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
|
||||||
|
<span class="node-name">{{ record.name }}</span>
|
||||||
|
<div class="card-actions" @click.stop>
|
||||||
|
<a-tooltip :title="t('info')">
|
||||||
|
<InfoCircleOutlined class="row-action-trigger" @click="openStats(record)" />
|
||||||
|
</a-tooltip>
|
||||||
|
<a-switch :checked="record.enable" size="small" @change="(v) => emit('toggle-enable', record, v)" />
|
||||||
|
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||||
|
<MoreOutlined class="row-action-trigger" @click.prevent />
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item key="probe" @click="emit('probe', record)">
|
||||||
|
<ThunderboltOutlined /> {{ t('pages.nodes.probe') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="edit" @click="emit('edit', record)">
|
||||||
|
<EditOutlined /> {{ t('edit') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="delete" class="danger-item" @click="emit('delete', record)">
|
||||||
|
<DeleteOutlined /> {{ t('delete') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isExpanded(record.id)" class="card-history">
|
||||||
|
<NodeHistoryPanel :node="record" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-modal v-if="isMobile" :open="!!statsNode" :footer="null" :width="360" centered
|
||||||
|
:title="statsNode ? statsNode.name : ''" @cancel="closeStats">
|
||||||
|
<div v-if="statsNode" class="card-stats">
|
||||||
|
<div v-if="statsNode.remark" class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.nodes.name') }}</span>
|
||||||
|
<span>{{ statsNode.remark }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.nodes.address') }}</span>
|
||||||
|
<a :href="statsNode.url" target="_blank" rel="noopener noreferrer"
|
||||||
|
:class="showAddress ? 'address-visible' : 'address-hidden'">{{ statsNode.url }}</a>
|
||||||
|
<a-tooltip :title="t('pages.index.toggleIpVisibility')">
|
||||||
|
<component :is="showAddress ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
|
||||||
|
@click="showAddress = !showAddress" />
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.nodes.status') }}</span>
|
||||||
|
<a-badge
|
||||||
|
:status="statusColor(statsNode.status) === 'green' ? 'success' : (statusColor(statsNode.status) === 'red' ? 'error' : 'default')" />
|
||||||
|
<span>{{ t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`) }}</span>
|
||||||
|
<a-tooltip v-if="statsNode.lastError" :title="statsNode.lastError">
|
||||||
|
<ExclamationCircleOutlined style="color: #faad14" />
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.nodes.cpu') }}</span>
|
||||||
|
<a-tag>{{ formatPct(statsNode.cpuPct) }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.nodes.mem') }}</span>
|
||||||
|
<a-tag>{{ formatPct(statsNode.memPct) }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.nodes.xrayVersion') }}</span>
|
||||||
|
<a-tag>{{ statsNode.xrayVersion || '-' }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.nodes.uptime') }}</span>
|
||||||
|
<a-tag>{{ formatUptime(statsNode.uptimeSecs) }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.nodes.latency') }}</span>
|
||||||
|
<a-tag>
|
||||||
|
<template v-if="statsNode.latencyMs > 0">{{ statsNode.latencyMs }} ms</template>
|
||||||
|
<template v-else>-</template>
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.nodes.lastHeartbeat') }}</span>
|
||||||
|
<a-tag>{{ relativeTime(statsNode.lastHeartbeat) }}</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- ====================== Desktop: a-table ======================== -->
|
||||||
|
<a-table v-else :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
|
||||||
size="middle" row-key="id">
|
size="middle" row-key="id">
|
||||||
<template #expandedRowRender="{ record }">
|
<template #expandedRowRender="{ record }">
|
||||||
<NodeHistoryPanel :node="record" />
|
<NodeHistoryPanel :node="record" />
|
||||||
@@ -97,9 +217,19 @@ function formatPct(p) {
|
|||||||
</template>
|
</template>
|
||||||
</a-table-column>
|
</a-table-column>
|
||||||
|
|
||||||
<a-table-column :title="t('pages.nodes.address')" data-index="url" :ellipsis="true">
|
<a-table-column data-index="url" :ellipsis="true">
|
||||||
|
<template #title>
|
||||||
|
<span class="address-header">
|
||||||
|
{{ t('pages.nodes.address') }}
|
||||||
|
<a-tooltip :title="t('pages.index.toggleIpVisibility')">
|
||||||
|
<component :is="showAddress ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
|
||||||
|
@click="showAddress = !showAddress" />
|
||||||
|
</a-tooltip>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<template #default="{ record }">
|
<template #default="{ record }">
|
||||||
<a :href="record.url" target="_blank" rel="noopener noreferrer">{{ record.url }}</a>
|
<a :href="record.url" target="_blank" rel="noopener noreferrer"
|
||||||
|
:class="showAddress ? 'address-visible' : 'address-hidden'">{{ record.url }}</a>
|
||||||
</template>
|
</template>
|
||||||
</a-table-column>
|
</a-table-column>
|
||||||
|
|
||||||
@@ -203,4 +333,133 @@ function formatPct(p) {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.address-header {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-toggle-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-toggle-icon:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-hidden {
|
||||||
|
filter: blur(5px);
|
||||||
|
transition: filter 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-visible {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-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) .node-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-expand {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-expand.is-expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-action-trigger {
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-history {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-item {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ useWebSocket({ nodes: applyNodesEvent });
|
|||||||
|
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
|
|
||||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
const requestUri = window.location.pathname;
|
const requestUri = window.location.pathname;
|
||||||
|
|
||||||
// === Form modal state =================================================
|
// === Form modal state =================================================
|
||||||
@@ -108,33 +108,33 @@ async function onToggleEnable(node, next) {
|
|||||||
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
||||||
<div v-if="!fetched" class="loading-spacer" />
|
<div v-if="!fetched" class="loading-spacer" />
|
||||||
|
|
||||||
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
|
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
|
||||||
<!-- Summary statistics card -->
|
<!-- Summary statistics card -->
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<a-card size="small" hoverable class="summary-card">
|
<a-card size="small" hoverable class="summary-card">
|
||||||
<a-row :gutter="[16, 12]">
|
<a-row :gutter="[16, isMobile ? 16 : 12]">
|
||||||
<a-col :sm="12" :md="6">
|
<a-col :xs="12" :sm="12" :md="6">
|
||||||
<CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)">
|
<CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<CloudServerOutlined />
|
<CloudServerOutlined />
|
||||||
</template>
|
</template>
|
||||||
</CustomStatistic>
|
</CustomStatistic>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :sm="12" :md="6">
|
<a-col :xs="12" :sm="12" :md="6">
|
||||||
<CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)">
|
<CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<CheckCircleOutlined style="color: #52c41a" />
|
<CheckCircleOutlined style="color: #52c41a" />
|
||||||
</template>
|
</template>
|
||||||
</CustomStatistic>
|
</CustomStatistic>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :sm="12" :md="6">
|
<a-col :xs="12" :sm="12" :md="6">
|
||||||
<CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)">
|
<CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<CloseCircleOutlined style="color: #ff4d4f" />
|
<CloseCircleOutlined style="color: #ff4d4f" />
|
||||||
</template>
|
</template>
|
||||||
</CustomStatistic>
|
</CustomStatistic>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :sm="12" :md="6">
|
<a-col :xs="12" :sm="12" :md="6">
|
||||||
<CustomStatistic :title="t('pages.nodes.avgLatency')"
|
<CustomStatistic :title="t('pages.nodes.avgLatency')"
|
||||||
:value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'">
|
:value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
@@ -172,8 +172,8 @@ async function onToggleEnable(node, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nodes-page.is-dark {
|
.nodes-page.is-dark {
|
||||||
--bg-page: #0a1222;
|
--bg-page: #1e1e1e;
|
||||||
--bg-card: #151f31;
|
--bg-card: #252526;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodes-page.is-dark.is-ultra {
|
.nodes-page.is-dark.is-ultra {
|
||||||
|
|||||||
@@ -153,6 +153,14 @@ onMounted(loadInboundTags);
|
|||||||
</template>
|
</template>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem paddings="small">
|
||||||
|
<template #title>Trusted proxy CIDRs</template>
|
||||||
|
<template #description>Comma-separated IPs/CIDRs allowed to set forwarded host, proto, and client IP headers.</template>
|
||||||
|
<template #control>
|
||||||
|
<a-input v-model:value="allSetting.trustedProxyCIDRs" placeholder="127.0.0.1/32,::1/128" />
|
||||||
|
</template>
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small">
|
<SettingListItem paddings="small">
|
||||||
<template #title>{{ t('pages.settings.pageSize') }}</template>
|
<template #title>{{ t('pages.settings.pageSize') }}</template>
|
||||||
<template #description>{{ t('pages.settings.pageSizeDesc') }}</template>
|
<template #description>{{ t('pages.settings.pageSizeDesc') }}</template>
|
||||||
@@ -298,8 +306,12 @@ onMounted(loadInboundTags);
|
|||||||
|
|
||||||
<SettingListItem paddings="small">
|
<SettingListItem paddings="small">
|
||||||
<template #title>{{ t('password') }}</template>
|
<template #title>{{ t('password') }}</template>
|
||||||
|
<template #description>
|
||||||
|
{{ allSetting.hasLdapPassword ? 'Configured; leave blank to keep current password.' : 'Not configured.' }}
|
||||||
|
</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input-password v-model:value="allSetting.ldapPassword" />
|
<a-input-password v-model:value="allSetting.ldapPassword"
|
||||||
|
:placeholder="allSetting.hasLdapPassword ? 'Configured - enter a new value to replace' : ''" />
|
||||||
</template>
|
</template>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
|
|||||||
@@ -52,10 +52,9 @@ async function sendUpdateUser() {
|
|||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/setting/updateUser', user);
|
const msg = await HttpUtil.post('/panel/setting/updateUser', user);
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
// Force re-login at the standard logout path; basePath is handled
|
await HttpUtil.post('/logout');
|
||||||
// by the Go router so a relative redirect is correct here.
|
const basePath = window.X_UI_BASE_PATH || '/';
|
||||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
window.location.replace(basePath);
|
||||||
window.location.replace(`${basePath}logout`);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
updating.value = false;
|
updating.value = false;
|
||||||
@@ -76,34 +75,41 @@ function updateUser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === API Token =========================================================
|
const apiTokens = ref([]);
|
||||||
// Surfaces the panel's API token so a remote central panel can register
|
const apiTokensLoading = ref(false);
|
||||||
// this instance as a node. Lazy-loaded on tab mount; rotation requires
|
const visibleTokenIds = ref(new Set());
|
||||||
// confirmation since it invalidates any cached value upstream.
|
const createOpen = ref(false);
|
||||||
const apiToken = ref('');
|
const createName = ref('');
|
||||||
const apiTokenLoading = ref(false);
|
const creating = ref(false);
|
||||||
const apiTokenRotating = ref(false);
|
|
||||||
|
|
||||||
async function loadApiToken() {
|
async function loadApiTokens() {
|
||||||
apiTokenLoading.value = true;
|
apiTokensLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.get('/panel/setting/getApiToken');
|
const msg = await HttpUtil.get('/panel/setting/apiTokens');
|
||||||
if (msg?.success) apiToken.value = msg.obj || '';
|
if (msg?.success) apiTokens.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||||
} finally {
|
} finally {
|
||||||
apiTokenLoading.value = false;
|
apiTokensLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyApiToken() {
|
function isTokenVisible(id) {
|
||||||
if (!apiToken.value) return;
|
return visibleTokenIds.value.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTokenVisibility(id) {
|
||||||
|
const next = new Set(visibleTokenIds.value);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
visibleTokenIds.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToken(token) {
|
||||||
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(apiToken.value);
|
await navigator.clipboard.writeText(token);
|
||||||
message.success(t('copySuccess'));
|
message.success(t('copySuccess'));
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// navigator.clipboard can be undefined on http:// — fall back to
|
|
||||||
// a transient input + execCommand path.
|
|
||||||
const ta = document.createElement('textarea');
|
const ta = document.createElement('textarea');
|
||||||
ta.value = apiToken.value;
|
ta.value = token;
|
||||||
document.body.appendChild(ta);
|
document.body.appendChild(ta);
|
||||||
ta.select();
|
ta.select();
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
@@ -112,28 +118,66 @@ async function copyApiToken() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function regenerateApiToken() {
|
function openCreateModal() {
|
||||||
|
createName.value = '';
|
||||||
|
createOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmCreateToken() {
|
||||||
|
const name = createName.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
message.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
creating.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name });
|
||||||
|
if (msg?.success) {
|
||||||
|
createOpen.value = false;
|
||||||
|
await loadApiTokens();
|
||||||
|
if (msg.obj?.id != null) {
|
||||||
|
const next = new Set(visibleTokenIds.value);
|
||||||
|
next.add(msg.obj.id);
|
||||||
|
visibleTokenIds.value = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
creating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteToken(row) {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: t('pages.nodes.regenerateConfirm'),
|
title: `${t('delete')} "${row.name}"?`,
|
||||||
okText: t('confirm'),
|
content: t('pages.settings.security.apiTokenDeleteWarning')
|
||||||
|
|| 'Any caller using this token will stop authenticating immediately.',
|
||||||
|
okText: t('delete'),
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
apiTokenRotating.value = true;
|
const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`);
|
||||||
try {
|
if (msg?.success) await loadApiTokens();
|
||||||
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
|
|
||||||
if (msg?.success) {
|
|
||||||
apiToken.value = msg.obj || '';
|
|
||||||
message.success(t('success'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
apiTokenRotating.value = false;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadApiToken);
|
async function toggleTokenEnabled(row) {
|
||||||
|
const target = !row.enabled;
|
||||||
|
const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target });
|
||||||
|
if (msg?.success) row.enabled = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskToken(token) {
|
||||||
|
if (!token) return '';
|
||||||
|
return '•'.repeat(Math.min(token.length, 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenDate(ts) {
|
||||||
|
if (!ts) return '';
|
||||||
|
return new Date(ts * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadApiTokens);
|
||||||
|
|
||||||
function toggleTwoFactor() {
|
function toggleTwoFactor() {
|
||||||
// Switch read-only — the actual flip happens after the modal succeeds.
|
// Switch read-only — the actual flip happens after the modal succeeds.
|
||||||
@@ -217,24 +261,144 @@ function toggleTwoFactor() {
|
|||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
|
|
||||||
<a-collapse-panel key="3" :header="t('pages.nodes.apiToken')">
|
<a-collapse-panel key="3" :header="t('pages.nodes.apiToken')">
|
||||||
<SettingListItem paddings="small">
|
<div class="api-token-section">
|
||||||
<template #title>{{ t('pages.nodes.apiToken') }}</template>
|
<div class="api-token-header">
|
||||||
<template #description>{{ t('pages.nodes.apiTokenHint') }}</template>
|
<p class="api-token-hint">{{ t('pages.nodes.apiTokenHint') }}</p>
|
||||||
<template #control>
|
<a-button type="primary" size="small" @click="openCreateModal">
|
||||||
<a-input-password :value="apiToken" readonly :loading="apiTokenLoading" style="min-width: 240px" />
|
+ {{ t('pages.settings.security.apiTokenNew') || 'New token' }}
|
||||||
</template>
|
|
||||||
</SettingListItem>
|
|
||||||
<a-list-item>
|
|
||||||
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
|
|
||||||
<a-button :disabled="!apiToken" @click="copyApiToken">{{ t('copy') }}</a-button>
|
|
||||||
<a-button danger :loading="apiTokenRotating" @click="regenerateApiToken">
|
|
||||||
{{ t('pages.nodes.regenerate') }}
|
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</div>
|
||||||
</a-list-item>
|
|
||||||
|
<a-spin :spinning="apiTokensLoading">
|
||||||
|
<a-empty v-if="!apiTokens.length && !apiTokensLoading"
|
||||||
|
:description="t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'" />
|
||||||
|
|
||||||
|
<div v-for="row in apiTokens" :key="row.id" class="api-token-row" :class="{ disabled: !row.enabled }">
|
||||||
|
<div class="api-token-row-head">
|
||||||
|
<div class="api-token-name-wrap">
|
||||||
|
<span class="api-token-name">{{ row.name }}</span>
|
||||||
|
<span class="api-token-created">{{ formatTokenDate(row.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="api-token-actions">
|
||||||
|
<a-switch size="small" :checked="row.enabled" @change="toggleTokenEnabled(row)" />
|
||||||
|
<a-button size="small" danger type="text" @click="confirmDeleteToken(row)">
|
||||||
|
{{ t('delete') }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="api-token-value-wrap">
|
||||||
|
<code class="api-token-value">{{ isTokenVisible(row.id) ? row.token : maskToken(row.token) }}</code>
|
||||||
|
<a-button size="small" @click="toggleTokenVisibility(row.id)">
|
||||||
|
{{ isTokenVisible(row.id)
|
||||||
|
? (t('pages.settings.security.hide') || 'Hide')
|
||||||
|
: (t('pages.settings.security.show') || 'Show') }}
|
||||||
|
</a-button>
|
||||||
|
<a-button size="small" @click="copyToken(row.token)">{{ t('copy') }}</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
</a-collapse>
|
</a-collapse>
|
||||||
|
|
||||||
|
<a-modal v-model:open="createOpen" :title="t('pages.settings.security.apiTokenNew') || 'New API token'"
|
||||||
|
:confirm-loading="creating" :ok-text="t('confirm')" :cancel-text="t('cancel')" @ok="confirmCreateToken">
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item :label="t('pages.settings.security.apiTokenName') || 'Name'" required>
|
||||||
|
<a-input v-model:value="createName" maxlength="64"
|
||||||
|
:placeholder="t('pages.settings.security.apiTokenNamePlaceholder') || 'e.g. central-panel-a'"
|
||||||
|
@keyup.enter="confirmCreateToken" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
<TwoFactorModal v-model:open="tfa.open" :title="tfa.title" :description="tfa.description" :token="tfa.token"
|
<TwoFactorModal v-model:open="tfa.open" :title="tfa.title" :description="tfa.description" :token="tfa.token"
|
||||||
:type="tfa.type" @confirm="onTfaConfirm" />
|
:type="tfa.type" @confirm="onTfaConfirm" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.api-token-section {
|
||||||
|
padding: 8px 20px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-token-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-token-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
opacity: 0.7;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-token-row {
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-token-row.disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-token-row-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-token-name-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-token-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-token-created {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-token-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-token-value-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-token-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(128, 128, 128, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Modal } from 'ant-design-vue';
|
import { Modal } from 'ant-design-vue';
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +26,7 @@ const { t } = useI18n();
|
|||||||
const { fetched, spinning, saveDisabled, allSetting, saveAll } = useAllSetting();
|
const { fetched, spinning, saveDisabled, allSetting, saveAll } = useAllSetting();
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
|
|
||||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
const requestUri = window.location.pathname;
|
const requestUri = window.location.pathname;
|
||||||
|
|
||||||
// AD-Vue 4's <a-back-top> calls `target()` after mount to find the
|
// AD-Vue 4's <a-back-top> calls `target()` after mount to find the
|
||||||
@@ -152,6 +152,35 @@ const confAlerts = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const alertVisible = ref(true);
|
const alertVisible = ref(true);
|
||||||
|
|
||||||
|
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
|
||||||
|
const slugToKey = (slug) => {
|
||||||
|
const i = tabSlugs.indexOf(slug);
|
||||||
|
return i >= 0 ? String(i + 1) : '1';
|
||||||
|
};
|
||||||
|
const keyToSlug = (key) => tabSlugs[Number(key) - 1] || tabSlugs[0];
|
||||||
|
|
||||||
|
const activeTabKey = ref(slugToKey(window.location.hash.slice(1)));
|
||||||
|
|
||||||
|
function onTabChange(key) {
|
||||||
|
activeTabKey.value = key;
|
||||||
|
const slug = keyToSlug(key);
|
||||||
|
if (window.location.hash !== `#${slug}`) {
|
||||||
|
history.replaceState(null, '', `#${slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTabFromHash() {
|
||||||
|
activeTabKey.value = slugToKey(window.location.hash.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('hashchange', syncTabFromHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('hashchange', syncTabFromHash);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -199,39 +228,49 @@ const alertVisible = ref(true);
|
|||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<a-tabs default-active-key="1">
|
<a-tabs :active-key="activeTabKey" :class="{ 'icons-only': isMobile }" @change="onTabChange">
|
||||||
<a-tab-pane key="1" class="tab-pane">
|
<a-tab-pane key="1" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<SettingOutlined />
|
<a-tooltip :title="isMobile ? t('pages.settings.panelSettings') : null">
|
||||||
<span>{{ t('pages.settings.panelSettings') }}</span>
|
<SettingOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.panelSettings') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<GeneralTab :all-setting="allSetting" />
|
<GeneralTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="2" class="tab-pane">
|
<a-tab-pane key="2" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<SafetyOutlined />
|
<a-tooltip :title="isMobile ? t('pages.settings.securitySettings') : null">
|
||||||
<span>{{ t('pages.settings.securitySettings') }}</span>
|
<SafetyOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.securitySettings') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<SecurityTab :all-setting="allSetting" />
|
<SecurityTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="3" class="tab-pane">
|
<a-tab-pane key="3" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<MessageOutlined />
|
<a-tooltip :title="isMobile ? t('pages.settings.TGBotSettings') : null">
|
||||||
<span>{{ t('pages.settings.TGBotSettings') }}</span>
|
<MessageOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.TGBotSettings') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<TelegramTab :all-setting="allSetting" />
|
<TelegramTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="4" class="tab-pane">
|
<a-tab-pane key="4" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<CloudServerOutlined />
|
<a-tooltip :title="isMobile ? t('pages.settings.subSettings') : null">
|
||||||
<span>{{ t('pages.settings.subSettings') }}</span>
|
<CloudServerOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.subSettings') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<SubscriptionGeneralTab :all-setting="allSetting" />
|
<SubscriptionGeneralTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane v-if="allSetting.subJsonEnable || allSetting.subClashEnable" key="5" class="tab-pane">
|
<a-tab-pane v-if="allSetting.subJsonEnable || allSetting.subClashEnable" key="5" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<CodeOutlined />
|
<a-tooltip :title="isMobile ? `${t('pages.settings.subSettings')} (Formats)` : null">
|
||||||
<span>{{ t('pages.settings.subSettings') }} (Formats)</span>
|
<CodeOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.subSettings') }} (Formats)</span>
|
||||||
</template>
|
</template>
|
||||||
<SubscriptionFormatsTab :all-setting="allSetting" />
|
<SubscriptionFormatsTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
@@ -256,8 +295,8 @@ const alertVisible = ref(true);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-page.is-dark {
|
.settings-page.is-dark {
|
||||||
--bg-page: #0a1222;
|
--bg-page: #1e1e1e;
|
||||||
--bg-card: #151f31;
|
--bg-card: #252526;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-page.is-dark.is-ultra {
|
.settings-page.is-dark.is-ultra {
|
||||||
@@ -304,4 +343,33 @@ const alertVisible = ref(true);
|
|||||||
.tab-pane {
|
.tab-pane {
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-wrap) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-list) {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-tab) {
|
||||||
|
flex: 1 1 0;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-tab .anticon) {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-operations) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ function normalizeSubPath() {
|
|||||||
</template>
|
</template>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem paddings="small">
|
||||||
|
<template #title>{{ t('pages.settings.subEmailInRemark') }}</template>
|
||||||
|
<template #description>{{ t('pages.settings.subEmailInRemarkDesc') }}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-switch v-model:checked="allSetting.subEmailInRemark" />
|
||||||
|
</template>
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
<a-divider>{{ t('pages.settings.subTitle') }}</a-divider>
|
<a-divider>{{ t('pages.settings.subTitle') }}</a-divider>
|
||||||
|
|
||||||
<SettingListItem paddings="small">
|
<SettingListItem paddings="small">
|
||||||
|
|||||||
@@ -23,9 +23,12 @@ defineProps({
|
|||||||
|
|
||||||
<SettingListItem paddings="small">
|
<SettingListItem paddings="small">
|
||||||
<template #title>{{ t('pages.settings.telegramToken') }}</template>
|
<template #title>{{ t('pages.settings.telegramToken') }}</template>
|
||||||
<template #description>{{ t('pages.settings.telegramTokenDesc') }}</template>
|
<template #description>
|
||||||
|
{{ allSetting.hasTgBotToken ? 'Configured; leave blank to keep current token.' : t('pages.settings.telegramTokenDesc') }}
|
||||||
|
</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input v-model:value="allSetting.tgBotToken" type="text" />
|
<a-input-password v-model:value="allSetting.tgBotToken"
|
||||||
|
:placeholder="allSetting.hasTgBotToken ? 'Configured - enter a new token to replace' : ''" />
|
||||||
</template>
|
</template>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import * as OTPAuth from 'otpauth';
|
import * as OTPAuth from 'otpauth';
|
||||||
import QRious from 'qrious';
|
|
||||||
|
|
||||||
import { ClipboardManager } from '@/utils';
|
import { ClipboardManager } from '@/utils';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
// Two flavors of this modal:
|
|
||||||
// • type='set' shows a QR code + manual key + a 6-digit verifier
|
|
||||||
// (used when enabling 2FA the first time);
|
|
||||||
// • type='confirm' shows just the 6-digit verifier (used when
|
|
||||||
// toggling 2FA off and when changing the admin user/password).
|
|
||||||
//
|
|
||||||
// Either way the parent supplies a `confirm(success: boolean)`
|
|
||||||
// callback — we run it with `true` only if the entered code matches
|
|
||||||
// the live TOTP value, otherwise `false`.
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
title: { type: String, default: '' },
|
title: { type: String, default: '' },
|
||||||
@@ -30,29 +19,10 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['update:open', 'confirm']);
|
const emit = defineEmits(['update:open', 'confirm']);
|
||||||
|
|
||||||
const enteredCode = ref('');
|
const enteredCode = ref('');
|
||||||
const qrCanvas = ref(null);
|
const qrValue = ref('');
|
||||||
|
|
||||||
let totp = null;
|
let totp = null;
|
||||||
|
|
||||||
// Byte-mode capacities (level L) for QR versions 1..40 — used to pick
|
|
||||||
// the matrix width up front so the canvas size is an exact multiple of
|
|
||||||
// pixelSize. Without this, QRious renders at floor(size/matrix) and
|
|
||||||
// centers, leaving a white margin around the QR.
|
|
||||||
const QR_L_BYTE_CAPACITY = [
|
|
||||||
17, 32, 53, 78, 106, 134, 154, 192, 230, 271,
|
|
||||||
321, 367, 425, 458, 520, 586, 644, 718, 792, 858,
|
|
||||||
929, 1003, 1091, 1171, 1273, 1367, 1465, 1528, 1628, 1732,
|
|
||||||
1840, 1952, 2068, 2188, 2303, 2431, 2563, 2699, 2809, 2953,
|
|
||||||
];
|
|
||||||
|
|
||||||
function pickQrMatrixWidth(value) {
|
|
||||||
const byteLen = new TextEncoder().encode(value).length;
|
|
||||||
for (let i = 0; i < QR_L_BYTE_CAPACITY.length; i++) {
|
|
||||||
if (byteLen <= QR_L_BYTE_CAPACITY[i]) return 17 + 4 * (i + 1);
|
|
||||||
}
|
|
||||||
return 17 + 4 * 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTotp() {
|
function buildTotp() {
|
||||||
totp = new OTPAuth.TOTP({
|
totp = new OTPAuth.TOTP({
|
||||||
issuer: '3x-ui',
|
issuer: '3x-ui',
|
||||||
@@ -62,43 +32,30 @@ function buildTotp() {
|
|||||||
period: 30,
|
period: 30,
|
||||||
secret: props.token,
|
secret: props.token,
|
||||||
});
|
});
|
||||||
}
|
qrValue.value = totp.toString();
|
||||||
|
|
||||||
async function paintQr() {
|
|
||||||
await nextTick();
|
|
||||||
if (!qrCanvas.value || !totp) return;
|
|
||||||
const value = totp.toString();
|
|
||||||
const matrixWidth = pickQrMatrixWidth(value);
|
|
||||||
const pixelSize = Math.max(1, Math.floor(200 / matrixWidth));
|
|
||||||
const exactSize = matrixWidth * pixelSize;
|
|
||||||
new QRious({
|
|
||||||
element: qrCanvas.value,
|
|
||||||
size: exactSize,
|
|
||||||
value,
|
|
||||||
background: 'white',
|
|
||||||
backgroundAlpha: 1,
|
|
||||||
foreground: 'black',
|
|
||||||
padding: 0,
|
|
||||||
level: 'L',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.open, (next) => {
|
watch(() => props.open, (next) => {
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
enteredCode.value = '';
|
enteredCode.value = '';
|
||||||
|
totp = null;
|
||||||
|
qrValue.value = '';
|
||||||
if (props.token) {
|
if (props.token) {
|
||||||
buildTotp();
|
buildTotp();
|
||||||
if (props.type === 'set') paintQr();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function close(success) {
|
function close(success, code = '') {
|
||||||
emit('confirm', success);
|
emit('confirm', success, code);
|
||||||
emit('update:open', false);
|
emit('update:open', false);
|
||||||
enteredCode.value = '';
|
enteredCode.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function onOk() {
|
function onOk() {
|
||||||
|
if (props.type === 'confirm' && !props.token) {
|
||||||
|
close(true, enteredCode.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!totp) return;
|
if (!totp) return;
|
||||||
if (totp.generate() === enteredCode.value) {
|
if (totp.generate() === enteredCode.value) {
|
||||||
close(true);
|
close(true);
|
||||||
@@ -124,9 +81,8 @@ async function copyToken() {
|
|||||||
<a-divider />
|
<a-divider />
|
||||||
<p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
|
<p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
|
||||||
<div class="qr-wrap">
|
<div class="qr-wrap">
|
||||||
<div class="qr-bg">
|
<a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
|
||||||
<canvas ref="qrCanvas" class="qr-cv" @click="copyToken" />
|
color="#000000" bg-color="#ffffff" error-level="L" :title="t('copy')" @click="copyToken" />
|
||||||
</div>
|
|
||||||
<span class="qr-token">{{ token }}</span>
|
<span class="qr-token">{{ token }}</span>
|
||||||
</div>
|
</div>
|
||||||
<a-divider />
|
<a-divider />
|
||||||
@@ -154,22 +110,11 @@ async function copyToken() {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-bg {
|
.qr-code {
|
||||||
width: 180px;
|
|
||||||
height: 180px;
|
|
||||||
background: #fff;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-cv {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 100% !important;
|
padding: 0 !important;
|
||||||
height: 100% !important;
|
background: #fff;
|
||||||
/* Drawing buffer is matrix-snapped (smaller than display size); scale
|
border-radius: 6px;
|
||||||
* up crisply so the QR fills the box without blurring. */
|
|
||||||
image-rendering: pixelated;
|
|
||||||
image-rendering: crisp-edges;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-token {
|
.qr-token {
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import {
|
|||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import QRious from 'qrious';
|
|
||||||
|
|
||||||
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
||||||
import {
|
import {
|
||||||
theme as themeState,
|
theme as themeState,
|
||||||
antdThemeConfig,
|
antdThemeConfig,
|
||||||
|
toggleTheme,
|
||||||
|
toggleUltra,
|
||||||
|
pauseAnimationsUntilLeave,
|
||||||
} from '@/composables/useTheme.js';
|
} from '@/composables/useTheme.js';
|
||||||
import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ const lastOnlineMs = Number(subData.lastOnline || 0);
|
|||||||
const subUrl = subData.subUrl || '';
|
const subUrl = subData.subUrl || '';
|
||||||
const subJsonUrl = subData.subJsonUrl || '';
|
const subJsonUrl = subData.subJsonUrl || '';
|
||||||
const subClashUrl = subData.subClashUrl || '';
|
const subClashUrl = subData.subClashUrl || '';
|
||||||
|
const subTitle = subData.subTitle || '';
|
||||||
const links = Array.isArray(subData.links) ? subData.links : [];
|
const links = Array.isArray(subData.links) ? subData.links : [];
|
||||||
// Panel's "Calendar Type" setting; controls whether expiry / lastOnline
|
// Panel's "Calendar Type" setting; controls whether expiry / lastOnline
|
||||||
// render in Gregorian or Jalali on this standalone subscription page.
|
// render in Gregorian or Jalali on this standalone subscription page.
|
||||||
@@ -71,32 +73,23 @@ function onLangChange(next) {
|
|||||||
LanguageManager.setLanguage(next);
|
LanguageManager.setLanguage(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
// QR code rendering ===========================================
|
/* Same Light -> Dark -> Ultra Dark -> Light cycle the panel sidebar
|
||||||
// Each ref points at a canvas element we paint after mount; QRious
|
* uses, so the standalone subscription page offers a one-click theme
|
||||||
// sizes itself from the element's `size` attribute.
|
* toggle without the popover ceremony. */
|
||||||
const subQr = ref(null);
|
function cycleTheme() {
|
||||||
const subJsonQr = ref(null);
|
pauseAnimationsUntilLeave('sub-theme-cycle');
|
||||||
const subClashQr = ref(null);
|
if (!themeState.isDark) {
|
||||||
|
toggleTheme();
|
||||||
function paintQr(canvas, value) {
|
if (themeState.isUltra) toggleUltra();
|
||||||
if (!canvas || !value) return;
|
} else if (!themeState.isUltra) {
|
||||||
new QRious({
|
toggleUltra();
|
||||||
element: canvas,
|
} else {
|
||||||
size: 220,
|
toggleUltra();
|
||||||
value,
|
toggleTheme();
|
||||||
background: 'white',
|
}
|
||||||
backgroundAlpha: 1,
|
|
||||||
foreground: 'black',
|
|
||||||
padding: 4,
|
|
||||||
level: 'M',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
const QR_SIZE = 240;
|
||||||
paintQr(subQr.value, subUrl);
|
|
||||||
paintQr(subJsonQr.value, subJsonUrl);
|
|
||||||
paintQr(subClashQr.value, subClashUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Actions =====================================================
|
// Actions =====================================================
|
||||||
async function copy(value) {
|
async function copy(value) {
|
||||||
@@ -128,7 +121,14 @@ function linkName(link, idx) {
|
|||||||
|
|
||||||
// iOS deep links — taken verbatim from the legacy subpage. Each
|
// iOS deep links — taken verbatim from the legacy subpage. Each
|
||||||
// client expects the sub URL in a slightly different param name.
|
// client expects the sub URL in a slightly different param name.
|
||||||
const shadowrocketUrl = computed(() => `sub://${btoa(subUrl)}`);
|
const shadowrocketUrl = computed(() => {
|
||||||
|
if (!subUrl) return '';
|
||||||
|
const separator = subUrl.includes('?') ? '&' : '?';
|
||||||
|
const rawUrl = subUrl + separator + 'flag=shadowrocket';
|
||||||
|
const base64Url = encodeURIComponent(btoa(rawUrl));
|
||||||
|
const remark = encodeURIComponent(subTitle || sId || 'Subscription');
|
||||||
|
return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
|
||||||
|
});
|
||||||
const v2boxUrl = computed(() => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`);
|
const v2boxUrl = computed(() => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`);
|
||||||
const streisandUrl = computed(() => `streisand://import/${encodeURIComponent(subUrl)}`);
|
const streisandUrl = computed(() => `streisand://import/${encodeURIComponent(subUrl)}`);
|
||||||
const v2raytunUrl = computed(() => subUrl);
|
const v2raytunUrl = computed(() => subUrl);
|
||||||
@@ -157,26 +157,45 @@ const themeClass = computed(() => ({
|
|||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<a-popover :title="t('menu.settings')" placement="bottomRight" trigger="click">
|
<a-space :size="8" align="center">
|
||||||
<template #content>
|
<button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
|
||||||
<a-space direction="vertical" :size="10" class="settings-popover">
|
@click="cycleTheme">
|
||||||
<ThemeSwitchLogin />
|
<svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
<span>{{ t('pages.settings.language') }}</span>
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
|
<circle cx="12" cy="12" r="4" />
|
||||||
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
|
<path
|
||||||
:value="l.value">
|
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" />
|
||||||
<span :aria-label="l.name">{{ l.icon }}</span>
|
</svg>
|
||||||
<span>{{ l.name }}</span>
|
<svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
</a-select-option>
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
</a-select>
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
</a-space>
|
</svg>
|
||||||
</template>
|
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
|
||||||
<a-button shape="circle">
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<template #icon>
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
<SettingOutlined />
|
<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 :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>
|
</template>
|
||||||
</a-button>
|
<a-button shape="circle">
|
||||||
</a-popover>
|
<template #icon>
|
||||||
|
<SettingOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-popover>
|
||||||
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ============== QR codes ============== -->
|
<!-- ============== QR codes ============== -->
|
||||||
@@ -184,7 +203,8 @@ const themeClass = computed(() => ({
|
|||||||
<a-col :xs="24" :sm="subJsonUrl || subClashUrl ? 12 : 24" class="qr-col">
|
<a-col :xs="24" :sm="subJsonUrl || subClashUrl ? 12 : 24" class="qr-col">
|
||||||
<div class="qr-box">
|
<div class="qr-box">
|
||||||
<a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
|
<a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
|
||||||
<canvas ref="subQr" class="qr-canvas" :title="t('copy')" @click="copy(subUrl)" />
|
<a-qrcode class="qr-code" :value="subUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||||
|
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subUrl)" />
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
|
<a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
|
||||||
@@ -192,13 +212,15 @@ const themeClass = computed(() => ({
|
|||||||
<a-tag color="purple" class="qr-tag">
|
<a-tag color="purple" class="qr-tag">
|
||||||
{{ t('pages.settings.subSettings') }} JSON
|
{{ t('pages.settings.subSettings') }} JSON
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<canvas ref="subJsonQr" class="qr-canvas" :title="t('copy')" @click="copy(subJsonUrl)" />
|
<a-qrcode class="qr-code" :value="subJsonUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||||
|
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subJsonUrl)" />
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
|
<a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
|
||||||
<div class="qr-box">
|
<div class="qr-box">
|
||||||
<a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
|
<a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
|
||||||
<canvas ref="subClashQr" class="qr-canvas" :title="t('copy')" @click="copy(subClashUrl)" />
|
<a-qrcode class="qr-code" :value="subClashUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||||
|
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subClashUrl)" />
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
@@ -299,8 +321,8 @@ const themeClass = computed(() => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subscription-page.is-dark {
|
.subscription-page.is-dark {
|
||||||
--bg-page: #0a1222;
|
--bg-page: #1e1e1e;
|
||||||
--bg-card: #151f31;
|
--bg-card: #252526;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-page.is-dark.is-ultra {
|
.subscription-page.is-dark.is-ultra {
|
||||||
@@ -336,7 +358,7 @@ const themeClass = computed(() => ({
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
width: 220px;
|
width: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-tag {
|
.qr-tag {
|
||||||
@@ -345,8 +367,9 @@ const themeClass = computed(() => ({
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-canvas {
|
.qr-code {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
padding: 0 !important;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -463,6 +486,45 @@ const themeClass = computed(() => ({
|
|||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-cycle {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
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: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-dark .theme-cycle {
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-dark .theme-cycle:hover,
|
||||||
|
.is-dark .theme-cycle:focus-visible {
|
||||||
|
background-color: rgba(64, 150, 255, 0.1);
|
||||||
|
color: #4096ff;
|
||||||
|
}
|
||||||
|
|
||||||
.lang-select {
|
.lang-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { Modal } from 'ant-design-vue';
|
import { Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
import BalancerFormModal from './BalancerFormModal.vue';
|
import BalancerFormModal from './BalancerFormModal.vue';
|
||||||
|
import JsonEditor from '@/components/JsonEditor.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ const { t } = useI18n();
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
templateSettings: { type: Object, default: null },
|
templateSettings: { type: Object, default: null },
|
||||||
clientReverseTags: { type: Array, default: () => [] },
|
clientReverseTags: { type: Array, default: () => [] },
|
||||||
|
isMobile: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const STRATEGY_LABELS = {
|
const STRATEGY_LABELS = {
|
||||||
@@ -30,6 +32,30 @@ const STRATEGY_LABELS = {
|
|||||||
leastPing: 'Least ping',
|
leastPing: 'Least ping',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Observatory defaults — values that the legacy panel seeded when a
|
||||||
|
// leastPing balancer first appeared. ProbeURL / interval follow Xray's
|
||||||
|
// own docs (https://xtls.github.io/config/observatory.html).
|
||||||
|
const DEFAULT_OBSERVATORY = Object.freeze({
|
||||||
|
subjectSelector: [],
|
||||||
|
probeURL: 'https://www.google.com/generate_204',
|
||||||
|
probeInterval: '1m',
|
||||||
|
enableConcurrency: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// BurstObservatory defaults — seeded when a leastLoad balancer is
|
||||||
|
// configured. Hicloud's generate_204 is the same connectivity probe
|
||||||
|
// the legacy panel used (https://xtls.github.io/config/burstobservatory.html).
|
||||||
|
const DEFAULT_BURST_OBSERVATORY = Object.freeze({
|
||||||
|
subjectSelector: [],
|
||||||
|
pingConfig: {
|
||||||
|
destination: 'https://www.google.com/generate_204',
|
||||||
|
interval: '1m',
|
||||||
|
connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
|
||||||
|
timeout: '5s',
|
||||||
|
sampling: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const rows = computed(() => {
|
const rows = computed(() => {
|
||||||
const list = props.templateSettings?.routing?.balancers || [];
|
const list = props.templateSettings?.routing?.balancers || [];
|
||||||
return list.map((b, idx) => ({
|
return list.map((b, idx) => ({
|
||||||
@@ -83,6 +109,41 @@ function ensureBalancersArray() {
|
|||||||
return props.templateSettings.routing.balancers;
|
return props.templateSettings.routing.balancers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep observatory / burstObservatory in sync with the configured
|
||||||
|
// balancers. leastPing balancers feed Observatory's subjectSelector;
|
||||||
|
// leastLoad balancers feed BurstObservatory's. When the matching
|
||||||
|
// strategy disappears we drop the observatory entirely so the rendered
|
||||||
|
// xray config stays minimal.
|
||||||
|
function collectSelectors(list) {
|
||||||
|
const out = new Set();
|
||||||
|
list.forEach((b) => (b.selector || []).forEach((s) => s && out.add(s)));
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncObservatories() {
|
||||||
|
const t = props.templateSettings;
|
||||||
|
if (!t) return;
|
||||||
|
const balancers = t.routing?.balancers || [];
|
||||||
|
|
||||||
|
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
|
||||||
|
if (leastPings.length > 0) {
|
||||||
|
if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
|
||||||
|
t.observatory.subjectSelector = collectSelectors(leastPings);
|
||||||
|
} else {
|
||||||
|
delete t.observatory;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leastLoads = balancers.filter((b) => b.strategy?.type === 'leastLoad');
|
||||||
|
if (leastLoads.length > 0) {
|
||||||
|
if (!t.burstObservatory) {
|
||||||
|
t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
|
||||||
|
}
|
||||||
|
t.burstObservatory.subjectSelector = collectSelectors(leastLoads);
|
||||||
|
} else {
|
||||||
|
delete t.burstObservatory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildWireBalancer(form) {
|
function buildWireBalancer(form) {
|
||||||
const out = {
|
const out = {
|
||||||
tag: form.tag,
|
tag: form.tag,
|
||||||
@@ -115,6 +176,7 @@ function onConfirm(form) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
syncObservatories();
|
||||||
modalOpen.value = false;
|
modalOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,17 +190,63 @@ function confirmDelete(idx) {
|
|||||||
// 4 leaves the modal open if onOk returns a truthy non-thenable
|
// 4 leaves the modal open if onOk returns a truthy non-thenable
|
||||||
// (it expects a Promise to await), and splice() returns the array
|
// (it expects a Promise to await), and splice() returns the array
|
||||||
// of removed items.
|
// of removed items.
|
||||||
onOk: () => { props.templateSettings.routing.balancers.splice(idx, 1); },
|
onOk: () => {
|
||||||
|
props.templateSettings.routing.balancers.splice(idx, 1);
|
||||||
|
syncObservatories();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = computed(() => [
|
const columns = computed(() => [
|
||||||
{ title: '#', key: 'action', align: 'center', width: 80 },
|
{ title: '#', key: 'action', align: 'center', width: 100 },
|
||||||
{ title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
|
{ title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
|
||||||
{ title: 'Strategy', key: 'strategy', align: 'center', width: 140 },
|
{ title: 'Strategy', key: 'strategy', align: 'center', width: 140 },
|
||||||
{ title: 'Selector', key: 'selector', align: 'center' },
|
{ title: 'Selector', key: 'selector', align: 'center' },
|
||||||
{ title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
|
{ title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// === Observatory / BurstObservatory inline editor ====================
|
||||||
|
// The legacy panel surfaced both top-level observatory blocks here as a
|
||||||
|
// raw JSON editor so admins could tune probeURL / interval / sampling
|
||||||
|
// without having to drop into the full xray template tab. We keep that
|
||||||
|
// affordance but only render it when the matching observatory exists —
|
||||||
|
// which is itself driven by syncObservatories() above.
|
||||||
|
const hasObservatory = computed(() => !!props.templateSettings?.observatory);
|
||||||
|
const hasBurstObservatory = computed(() => !!props.templateSettings?.burstObservatory);
|
||||||
|
const showObsEditor = computed(() => hasObservatory.value || hasBurstObservatory.value);
|
||||||
|
|
||||||
|
const obsView = ref('observatory');
|
||||||
|
|
||||||
|
// Keep the radio selection valid as observatories appear/disappear —
|
||||||
|
// e.g. deleting the last leastPing balancer should flip the editor to
|
||||||
|
// the burstObservatory pane instead of leaving it pointing at the
|
||||||
|
// (now-removed) observatory key.
|
||||||
|
watch(showObsEditor, () => {
|
||||||
|
if (obsView.value === 'observatory' && !hasObservatory.value && hasBurstObservatory.value) {
|
||||||
|
obsView.value = 'burstObservatory';
|
||||||
|
} else if (obsView.value === 'burstObservatory' && !hasBurstObservatory.value && hasObservatory.value) {
|
||||||
|
obsView.value = 'observatory';
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const obsText = computed({
|
||||||
|
get: () => {
|
||||||
|
const t = props.templateSettings;
|
||||||
|
if (!t) return '';
|
||||||
|
const src = obsView.value === 'observatory' ? t.observatory : t.burstObservatory;
|
||||||
|
return src ? JSON.stringify(src, null, 2) : '';
|
||||||
|
},
|
||||||
|
set: (next) => {
|
||||||
|
let parsed;
|
||||||
|
try { parsed = JSON.parse(next); } catch (_e) { return; }
|
||||||
|
if (!props.templateSettings) return;
|
||||||
|
if (obsView.value === 'observatory') {
|
||||||
|
props.templateSettings.observatory = parsed;
|
||||||
|
} else {
|
||||||
|
props.templateSettings.burstObservatory = parsed;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -160,25 +268,39 @@ const columns = computed(() => [
|
|||||||
{{ t('pages.xray.Balancers') }}
|
{{ t('pages.xray.Balancers') }}
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" size="small" bordered>
|
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
|
||||||
|
size="small" :scroll="{ x: 400 }">
|
||||||
<template #bodyCell="{ column, record, index }">
|
<template #bodyCell="{ column, record, index }">
|
||||||
<template v-if="column.key === 'action'">
|
<template v-if="column.key === 'action'">
|
||||||
<span class="row-index">{{ index + 1 }}</span>
|
<div class="action-cell">
|
||||||
<a-dropdown :trigger="['click']">
|
<span class="row-index">{{ index + 1 }}</span>
|
||||||
<a-button shape="circle" size="small" class="action-btn">
|
|
||||||
<MoreOutlined />
|
<div :class="!isMobile ? 'action-buttons' : ''">
|
||||||
</a-button>
|
<a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
|
||||||
<template #overlay>
|
<template #icon>
|
||||||
<a-menu>
|
<EditOutlined />
|
||||||
<a-menu-item @click="openEdit(index)">
|
</template>
|
||||||
<EditOutlined /> {{ t('edit') }}
|
</a-button>
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item class="danger" @click="confirmDelete(index)">
|
<a-dropdown :trigger="['click']">
|
||||||
<DeleteOutlined /> {{ t('delete') }}
|
<a-button shape="circle" size="small">
|
||||||
</a-menu-item>
|
<template #icon>
|
||||||
</a-menu>
|
<MoreOutlined />
|
||||||
</template>
|
</template>
|
||||||
</a-dropdown>
|
</a-button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item v-if="isMobile" @click="openEdit(index)">
|
||||||
|
<EditOutlined /> {{ t('edit') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item class="danger" @click="confirmDelete(index)">
|
||||||
|
<DeleteOutlined /> {{ t('delete') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'strategy'">
|
<template v-else-if="column.key === 'strategy'">
|
||||||
@@ -192,6 +314,15 @@ const columns = computed(() => [
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
|
|
||||||
|
<template v-if="showObsEditor">
|
||||||
|
<a-divider :style="{ margin: '8px 0' }" />
|
||||||
|
<a-radio-group v-model:value="obsView" button-style="solid" size="small">
|
||||||
|
<a-radio-button v-if="hasObservatory" value="observatory">Observatory</a-radio-button>
|
||||||
|
<a-radio-button v-if="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
<JsonEditor v-model:value="obsText" min-height="220px" max-height="480px" />
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<BalancerFormModal v-model:open="modalOpen" :balancer="editingBalancer" :outbound-tags="outboundTags"
|
<BalancerFormModal v-model:open="modalOpen" :balancer="editingBalancer" :outbound-tags="outboundTags"
|
||||||
@@ -200,17 +331,29 @@ const columns = computed(() => [
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.action-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.row-index {
|
.row-index {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
margin-right: 6px;
|
min-width: 18px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-buttons {
|
||||||
vertical-align: middle;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger {
|
.danger {
|
||||||
color: #ff4d4f;
|
color: #ff4d4f;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ const props = defineProps({
|
|||||||
|
|
||||||
const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
|
const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
|
||||||
|
|
||||||
|
const dnsFieldOmit = Object.freeze(Object.create(null));
|
||||||
|
|
||||||
|
function dnsValueEmptyForOmit(v) {
|
||||||
|
if (v === undefined || v === null) return true;
|
||||||
|
if (typeof v === 'string') return v.trim() === '';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const enableDNS = computed({
|
const enableDNS = computed({
|
||||||
get: () => !!props.templateSettings?.dns,
|
get: () => !!props.templateSettings?.dns,
|
||||||
set: (next) => {
|
set: (next) => {
|
||||||
@@ -29,7 +37,6 @@ const enableDNS = computed({
|
|||||||
if (next) {
|
if (next) {
|
||||||
props.templateSettings.dns = {
|
props.templateSettings.dns = {
|
||||||
tag: 'dns_inbound',
|
tag: 'dns_inbound',
|
||||||
clientIp: '',
|
|
||||||
queryStrategy: 'UseIP',
|
queryStrategy: 'UseIP',
|
||||||
disableCache: false,
|
disableCache: false,
|
||||||
disableFallback: false,
|
disableFallback: false,
|
||||||
@@ -50,16 +57,30 @@ const enableDNS = computed({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function dnsField(field, fallback) {
|
function dnsField(field, fallback) {
|
||||||
|
const omitWhenUnset = fallback === dnsFieldOmit;
|
||||||
return computed({
|
return computed({
|
||||||
get: () => props.templateSettings?.dns?.[field] ?? fallback,
|
get: () => {
|
||||||
|
const raw = props.templateSettings?.dns?.[field];
|
||||||
|
if (fallback === dnsFieldOmit) return raw ?? '';
|
||||||
|
return raw ?? fallback;
|
||||||
|
},
|
||||||
set: (v) => {
|
set: (v) => {
|
||||||
if (props.templateSettings?.dns) props.templateSettings.dns[field] = v;
|
if (!props.templateSettings?.dns) return;
|
||||||
|
if (omitWhenUnset) {
|
||||||
|
if (dnsValueEmptyForOmit(v)) {
|
||||||
|
if (field in props.templateSettings.dns) delete props.templateSettings.dns[field];
|
||||||
|
} else {
|
||||||
|
props.templateSettings.dns[field] = v;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
props.templateSettings.dns[field] = v;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const dnsTag = dnsField('tag', 'dns_inbound');
|
const dnsTag = dnsField('tag', 'dns_inbound');
|
||||||
const dnsClientIp = dnsField('clientIp', '');
|
const dnsClientIp = dnsField('clientIp', dnsFieldOmit);
|
||||||
const dnsStrategy = dnsField('queryStrategy', 'UseIP');
|
const dnsStrategy = dnsField('queryStrategy', 'UseIP');
|
||||||
const dnsDisableCache = dnsField('disableCache', false);
|
const dnsDisableCache = dnsField('disableCache', false);
|
||||||
const dnsDisableFallback = dnsField('disableFallback', false);
|
const dnsDisableFallback = dnsField('disableFallback', false);
|
||||||
|
|||||||
@@ -222,6 +222,12 @@ function resetOutbound() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function close() { emit('update:open', false); }
|
function close() { emit('update:open', false); }
|
||||||
|
|
||||||
|
function loadColor(load) {
|
||||||
|
if (load < 30) return 'green';
|
||||||
|
if (load < 70) return 'orange';
|
||||||
|
return 'red';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -299,9 +305,13 @@ function close() { emit('update:open', false); }
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item v-if="filteredServers.length > 0" label="Server">
|
<a-form-item v-if="filteredServers.length > 0" label="Server">
|
||||||
<a-select v-model:value="serverId">
|
<a-select v-model:value="serverId" show-search option-filter-prop="label">
|
||||||
<a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id">
|
<a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id"
|
||||||
{{ s.cityName }} - {{ s.name }} (load: {{ s.load }}%)
|
:label="`${s.cityName} ${s.name} ${s.hostname}`">
|
||||||
|
<span class="server-row">
|
||||||
|
<span class="server-name">{{ s.cityName }} - {{ s.name }}</span>
|
||||||
|
<a-tag :color="loadColor(s.load)" class="server-load-tag">{{ s.load }}%</a-tag>
|
||||||
|
</span>
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -376,4 +386,22 @@ function close() { emit('update:open', false); }
|
|||||||
.ml-8 {
|
.ml-8 {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-load-tag {
|
||||||
|
margin-right: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
DNSRuleActions,
|
DNSRuleActions,
|
||||||
} from '@/models/outbound.js';
|
} from '@/models/outbound.js';
|
||||||
import FinalMaskForm from '@/components/FinalMaskForm.vue';
|
import FinalMaskForm from '@/components/FinalMaskForm.vue';
|
||||||
|
import JsonEditor from '@/components/JsonEditor.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -34,7 +35,6 @@ const props = defineProps({
|
|||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
outbound: { type: Object, default: null },
|
outbound: { type: Object, default: null },
|
||||||
existingTags: { type: Array, default: () => [] },
|
existingTags: { type: Array, default: () => [] },
|
||||||
inboundTags: { type: Array, default: () => [] },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:open', 'confirm']);
|
const emit = defineEmits(['update:open', 'confirm']);
|
||||||
@@ -81,8 +81,17 @@ watch(() => props.open, (next) => {
|
|||||||
primeAdvancedJson();
|
primeAdvancedJson();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(activeKey, (key) => {
|
let isRevertingTab = false;
|
||||||
if (key === '2') primeAdvancedJson();
|
watch(activeKey, (key, prev) => {
|
||||||
|
if (isRevertingTab) { isRevertingTab = false; return; }
|
||||||
|
if (key === '2') {
|
||||||
|
primeAdvancedJson();
|
||||||
|
} else if (key === '1' && prev === '2') {
|
||||||
|
if (!applyAdvancedJsonToForm()) {
|
||||||
|
isRevertingTab = true;
|
||||||
|
activeKey.value = '2';
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function primeAdvancedJson() {
|
function primeAdvancedJson() {
|
||||||
@@ -94,6 +103,33 @@ function primeAdvancedJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyAdvancedJsonToForm() {
|
||||||
|
const raw = advancedJson.value.trim();
|
||||||
|
if (!raw) return true;
|
||||||
|
let currentJson = '';
|
||||||
|
try {
|
||||||
|
currentJson = JSON.stringify(outbound.value?.toJson() ?? {}, null, 2);
|
||||||
|
} catch (_e) { /* fall through */ }
|
||||||
|
if (raw === currentJson.trim()) return true;
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(`JSON: ${e.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fallbackTag = outbound.value?.tag;
|
||||||
|
const next = Outbound.fromJson(parsed);
|
||||||
|
if (!next.tag && fallbackTag) next.tag = fallbackTag;
|
||||||
|
outbound.value = next;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
message.error(`JSON: ${e.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function close() { emit('update:open', false); }
|
function close() { emit('update:open', false); }
|
||||||
|
|
||||||
function onProtocolChange(next) {
|
function onProtocolChange(next) {
|
||||||
@@ -132,27 +168,15 @@ const tagHelp = computed(() => {
|
|||||||
// ============== Submit ==============
|
// ============== Submit ==============
|
||||||
function onOk() {
|
function onOk() {
|
||||||
if (!outbound.value) return;
|
if (!outbound.value) return;
|
||||||
|
if (activeKey.value === '2' && !applyAdvancedJsonToForm()) return;
|
||||||
if (!outbound.value.tag?.trim()) {
|
if (!outbound.value.tag?.trim()) {
|
||||||
message.error(t('somethingWentWrong'));
|
message.error('Tag is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (duplicateTag.value) {
|
if (duplicateTag.value) {
|
||||||
message.error(t('somethingWentWrong'));
|
message.error('Tag already used by another outbound');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If user spent time in the JSON tab, prefer that body — round-trip
|
|
||||||
// it through Outbound.fromJson so the wire shape stays consistent.
|
|
||||||
if (activeKey.value === '2' && advancedJson.value.trim()) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(advancedJson.value);
|
|
||||||
const built = Outbound.fromJson(parsed);
|
|
||||||
emit('confirm', built.toJson());
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
message.error(`JSON: ${e.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
emit('confirm', outbound.value.toJson());
|
emit('confirm', outbound.value.toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +195,7 @@ function convertLink() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
outbound.value = next;
|
outbound.value = next;
|
||||||
|
primeAdvancedJson();
|
||||||
linkInput.value = '';
|
linkInput.value = '';
|
||||||
message.success('Link imported successfully...');
|
message.success('Link imported successfully...');
|
||||||
activeKey.value = '1';
|
activeKey.value = '1';
|
||||||
@@ -318,10 +343,8 @@ function regenerateWgKeys() {
|
|||||||
<!-- ============== Loopback ============== -->
|
<!-- ============== Loopback ============== -->
|
||||||
<template v-if="isLoopback">
|
<template v-if="isLoopback">
|
||||||
<a-form-item label="Inbound tag">
|
<a-form-item label="Inbound tag">
|
||||||
<a-auto-complete v-model:value="outbound.settings.inboundTag"
|
<a-input v-model:value="outbound.settings.inboundTag"
|
||||||
:options="inboundTags.map((tag) => ({ value: tag }))"
|
placeholder="inbound tag using in routing rules" />
|
||||||
:filter-option="(input, option) => option.value.toLowerCase().includes(input.toLowerCase())"
|
|
||||||
placeholder="tag of an existing inbound to re-route into" />
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -967,8 +990,7 @@ function regenerateWgKeys() {
|
|||||||
<a-button>Convert</a-button>
|
<a-button>Convert</a-button>
|
||||||
</template>
|
</template>
|
||||||
</a-input-search>
|
</a-input-search>
|
||||||
<a-textarea v-model:value="advancedJson" :auto-size="{ minRows: 14, maxRows: 30 }" spellcheck="false"
|
<JsonEditor v-model:value="advancedJson" min-height="360px" max-height="600px" />
|
||||||
class="json-editor" />
|
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
@@ -1011,11 +1033,6 @@ function regenerateWgKeys() {
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-editor {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* AD-Vue 4 renders a-checkbox children inside a-checkbox-group as
|
/* AD-Vue 4 renders a-checkbox children inside a-checkbox-group as
|
||||||
* inline-block, but inside a narrow form wrapper they can wrap
|
* inline-block, but inside a narrow form wrapper they can wrap
|
||||||
* inconsistently. Force a clean horizontal row with even gaps. */
|
* inconsistently. Force a clean horizontal row with even gaps. */
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
ArrowUpOutlined,
|
ArrowUpOutlined,
|
||||||
ArrowDownOutlined,
|
ArrowDownOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { Modal } from 'ant-design-vue';
|
import { Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
@@ -25,30 +26,18 @@ import OutboundFormModal from './OutboundFormModal.vue';
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
// Outbounds tab — list + actions over templateSettings.outbounds.
|
|
||||||
// Mirrors the legacy outbound table layout (identity / address /
|
|
||||||
// traffic / test result / test button) plus the row action menu
|
|
||||||
// (set first / edit / reset traffic / delete). Mobile collapses to
|
|
||||||
// a card list.
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
templateSettings: { type: Object, default: null },
|
templateSettings: { type: Object, default: null },
|
||||||
outboundsTraffic: { type: Array, default: () => [] },
|
outboundsTraffic: { type: Array, default: () => [] },
|
||||||
outboundTestStates: { type: Object, default: () => ({}) },
|
outboundTestStates: { type: Object, default: () => ({}) },
|
||||||
|
testingAll: { type: Boolean, default: false },
|
||||||
inboundTags: { type: Array, default: () => [] },
|
inboundTags: { type: Array, default: () => [] },
|
||||||
isMobile: { type: Boolean, default: false },
|
isMobile: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const inboundTagOptions = computed(() => {
|
const emit = defineEmits(['reset-traffic', 'test', 'test-all', 'show-warp', 'show-nord', 'delete']);
|
||||||
const out = new Set();
|
|
||||||
for (const ib of props.templateSettings?.inbounds || []) {
|
|
||||||
if (ib.tag) out.add(ib.tag);
|
|
||||||
}
|
|
||||||
for (const t of props.inboundTags || []) out.add(t);
|
|
||||||
return [...out];
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['reset-traffic', 'test', 'show-warp', 'show-nord', 'delete']);
|
const testMode = ref('tcp');
|
||||||
|
|
||||||
// === Modal state ====================================================
|
// === Modal state ====================================================
|
||||||
const modalOpen = ref(false);
|
const modalOpen = ref(false);
|
||||||
@@ -141,10 +130,13 @@ function outboundAddresses(o) {
|
|||||||
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
|
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUntestable(o) {
|
function isUntestable(o, mode = testMode.value) {
|
||||||
return o.protocol === Protocols.Blackhole
|
if (!o) return true;
|
||||||
|
if (o.protocol === Protocols.Blackhole
|
||||||
|| o.protocol === Protocols.Loopback
|
|| o.protocol === Protocols.Loopback
|
||||||
|| o.tag === 'blocked';
|
|| o.tag === 'blocked') return true;
|
||||||
|
if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
function isTesting(idx) {
|
function isTesting(idx) {
|
||||||
return !!props.outboundTestStates?.[idx]?.testing;
|
return !!props.outboundTestStates?.[idx]?.testing;
|
||||||
@@ -156,14 +148,20 @@ function showSecurity(security) {
|
|||||||
return security === 'tls' || security === 'reality';
|
return security === 'tls' || security === 'reality';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasBreakdown(r) {
|
||||||
|
if (!r) return false;
|
||||||
|
if (r.endpoints?.length) return true;
|
||||||
|
return !!(r.ttfbMs || r.tlsMs || r.connectMs || r.dnsMs || r.statusCode || r.error);
|
||||||
|
}
|
||||||
|
|
||||||
// === Columns ========================================================
|
// === Columns ========================================================
|
||||||
// Computed so titles re-render after a locale swap.
|
// Computed so titles re-render after a locale swap.
|
||||||
const columns = computed(() => [
|
const columns = computed(() => [
|
||||||
{ title: '#', key: 'action', align: 'center', width: 70 },
|
{ title: '#', key: 'action', align: 'center', width: 100 },
|
||||||
{ title: 'Tag', key: 'identity', align: 'left', width: 220 },
|
{ title: 'Tag', key: 'identity', align: 'left' },
|
||||||
{ title: t('pages.inbounds.address'), key: 'address', align: 'left', width: 230 },
|
{ title: t('pages.inbounds.address'), key: 'address', align: 'left' },
|
||||||
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
|
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
|
||||||
{ title: t('check'), key: 'testResult', align: 'left', width: 140 },
|
{ title: t('pages.xray.latency') !== 'pages.xray.latency' ? t('pages.xray.latency') : 'Latency', key: 'testResult', align: 'left', width: 140 },
|
||||||
{ title: t('check'), key: 'test', align: 'center', width: 80 },
|
{ title: t('check'), key: 'test', align: 'center', width: 80 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -177,8 +175,8 @@ const rows = computed(() => {
|
|||||||
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
|
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<a-row :gutter="[12, 12]" align="middle" justify="space-between">
|
<a-row :gutter="[12, 12]" align="middle" justify="space-between">
|
||||||
<a-col :xs="24" :sm="14">
|
<a-col :xs="24" :sm="12">
|
||||||
<a-space size="small">
|
<a-space size="small" wrap>
|
||||||
<a-button type="primary" @click="openAdd">
|
<a-button type="primary" @click="openAdd">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<PlusOutlined />
|
<PlusOutlined />
|
||||||
@@ -199,15 +197,29 @@ const rows = computed(() => {
|
|||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :sm="10" class="toolbar-right">
|
<a-col :xs="24" :sm="12" class="toolbar-right">
|
||||||
<a-popconfirm placement="topRight" :ok-text="t('reset')" :cancel-text="t('cancel')"
|
<a-space size="small" wrap>
|
||||||
:title="t('pages.inbounds.resetAllTrafficContent')" @confirm="emit('reset-traffic', '-alltags-')">
|
<a-tooltip :title="t('pages.xray.testModeHint') !== 'pages.xray.testModeHint' ? t('pages.xray.testModeHint') : 'TCP: fast dial-only probe. HTTP: full request through xray.'">
|
||||||
<a-button>
|
<a-radio-group v-model:value="testMode" size="small" button-style="solid">
|
||||||
|
<a-radio-button value="tcp">TCP</a-radio-button>
|
||||||
|
<a-radio-button value="http">HTTP</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-button type="primary" :loading="testingAll" @click="emit('test-all', testMode)">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<RetweetOutlined />
|
<PlayCircleOutlined />
|
||||||
</template>
|
</template>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.xray.testAll') !== 'pages.xray.testAll' ? t('pages.xray.testAll') : 'Test all' }}</span>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-popconfirm>
|
<a-popconfirm placement="topRight" :ok-text="t('reset')" :cancel-text="t('cancel')"
|
||||||
|
:title="t('pages.inbounds.resetAllTrafficContent')" @confirm="emit('reset-traffic', '-alltags-')">
|
||||||
|
<a-button>
|
||||||
|
<template #icon>
|
||||||
|
<RetweetOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
@@ -262,15 +274,39 @@ const rows = computed(() => {
|
|||||||
<span class="traffic-sep" />
|
<span class="traffic-sep" />
|
||||||
<span class="traffic-down">↓ {{ SizeFormatter.sizeFormat(trafficFor(record).down) }}</span>
|
<span class="traffic-down">↓ {{ SizeFormatter.sizeFormat(trafficFor(record).down) }}</span>
|
||||||
<span class="card-test">
|
<span class="card-test">
|
||||||
<span v-if="testResult(index)" :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
|
<a-popover v-if="testResult(index)" placement="topRight"
|
||||||
<CheckCircleFilled v-if="testResult(index).success" />
|
:overlay-class-name="'outbound-test-popover'">
|
||||||
<CloseCircleFilled v-else />
|
<template #content>
|
||||||
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
|
<div class="timing-breakdown">
|
||||||
<span v-else>failed</span>
|
<div class="td-head" :class="testResult(index).success ? 'ok' : 'fail'">
|
||||||
</span>
|
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
|
||||||
|
<span v-else>{{ testResult(index).error || 'failed' }}</span>
|
||||||
|
<span v-if="testResult(index).mode" class="mode-badge">{{ testResult(index).mode.toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="hasBreakdown(testResult(index))">
|
||||||
|
<div v-if="testResult(index).ttfbMs">TTFB: {{ testResult(index).ttfbMs }} ms</div>
|
||||||
|
<div v-if="testResult(index).tlsMs">TLS: {{ testResult(index).tlsMs }} ms</div>
|
||||||
|
<div v-if="testResult(index).connectMs">Connect: {{ testResult(index).connectMs }} ms</div>
|
||||||
|
<div v-if="testResult(index).dnsMs">DNS: {{ testResult(index).dnsMs }} ms</div>
|
||||||
|
<div v-if="testResult(index).statusCode">HTTP {{ testResult(index).statusCode }}</div>
|
||||||
|
<div v-for="ep in testResult(index).endpoints || []" :key="ep.address" class="endpoint-row">
|
||||||
|
<span :class="ep.success ? 'dot-ok' : 'dot-fail'">●</span>
|
||||||
|
<span class="ep-addr">{{ ep.address }}</span>
|
||||||
|
<span class="ep-meta">{{ ep.success ? `${ep.delay} ms` : (ep.error || 'failed') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
|
||||||
|
<CheckCircleFilled v-if="testResult(index).success" />
|
||||||
|
<CloseCircleFilled v-else />
|
||||||
|
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
|
||||||
|
<span v-else>failed</span>
|
||||||
|
</span>
|
||||||
|
</a-popover>
|
||||||
<LoadingOutlined v-else-if="isTesting(index)" />
|
<LoadingOutlined v-else-if="isTesting(index)" />
|
||||||
<a-button type="primary" shape="circle" size="small" :loading="isTesting(index)"
|
<a-button type="primary" shape="circle" size="small" :loading="isTesting(index)"
|
||||||
:disabled="isUntestable(record) || isTesting(index)" @click="emit('test', index)">
|
:disabled="isUntestable(record, testMode) || isTesting(index)" @click="emit('test', index, testMode)">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<ThunderboltOutlined />
|
<ThunderboltOutlined />
|
||||||
</template>
|
</template>
|
||||||
@@ -286,33 +322,41 @@ const rows = computed(() => {
|
|||||||
<template v-if="column.key === 'action'">
|
<template v-if="column.key === 'action'">
|
||||||
<div class="action-cell">
|
<div class="action-cell">
|
||||||
<span class="row-index">{{ index + 1 }}</span>
|
<span class="row-index">{{ index + 1 }}</span>
|
||||||
<a-dropdown :trigger="['click']">
|
|
||||||
<a-button shape="circle" size="small">
|
<div class="action-buttons">
|
||||||
<MoreOutlined />
|
<a-button shape="circle" size="small" @click="openEdit(index)">
|
||||||
</a-button>
|
<template #icon>
|
||||||
<template #overlay>
|
<EditOutlined />
|
||||||
<a-menu>
|
</template>
|
||||||
<a-menu-item v-if="index > 0" @click="setFirst(index)">
|
</a-button>
|
||||||
<VerticalAlignTopOutlined /> Move to top
|
|
||||||
</a-menu-item>
|
<a-dropdown :trigger="['click']">
|
||||||
<a-menu-item @click="openEdit(index)">
|
<a-button shape="circle" size="small">
|
||||||
<EditOutlined /> Edit
|
<template #icon>
|
||||||
</a-menu-item>
|
<MoreOutlined />
|
||||||
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
|
</template>
|
||||||
<ArrowUpOutlined />
|
</a-button>
|
||||||
</a-menu-item>
|
<template #overlay>
|
||||||
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
|
<a-menu>
|
||||||
<ArrowDownOutlined />
|
<a-menu-item v-if="index > 0" @click="setFirst(index)">
|
||||||
</a-menu-item>
|
<VerticalAlignTopOutlined /> Move to top
|
||||||
<a-menu-item @click="emit('reset-traffic', record.tag || '')">
|
</a-menu-item>
|
||||||
<RetweetOutlined /> Reset traffic
|
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
|
||||||
</a-menu-item>
|
<ArrowUpOutlined />
|
||||||
<a-menu-item class="danger" @click="confirmDelete(index)">
|
</a-menu-item>
|
||||||
<DeleteOutlined /> Delete
|
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
|
||||||
</a-menu-item>
|
<ArrowDownOutlined />
|
||||||
</a-menu>
|
</a-menu-item>
|
||||||
</template>
|
<a-menu-item @click="emit('reset-traffic', record.tag || '')">
|
||||||
</a-dropdown>
|
<RetweetOutlined /> Reset traffic
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item class="danger" @click="confirmDelete(index)">
|
||||||
|
<DeleteOutlined /> Delete
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -350,22 +394,44 @@ const rows = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'testResult'">
|
<template v-else-if="column.key === 'testResult'">
|
||||||
<span v-if="testResult(index)" :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
|
<a-popover v-if="testResult(index)" placement="topLeft"
|
||||||
<CheckCircleFilled v-if="testResult(index).success" />
|
:overlay-class-name="'outbound-test-popover'">
|
||||||
<CloseCircleFilled v-else />
|
<template #content>
|
||||||
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
|
<div class="timing-breakdown">
|
||||||
<a-tooltip v-else :title="testResult(index).error">
|
<div class="td-head" :class="testResult(index).success ? 'ok' : 'fail'">
|
||||||
<span>failed</span>
|
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
|
||||||
</a-tooltip>
|
<span v-else>{{ testResult(index).error || 'failed' }}</span>
|
||||||
</span>
|
<span v-if="testResult(index).mode" class="mode-badge">{{ testResult(index).mode.toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="hasBreakdown(testResult(index))">
|
||||||
|
<div v-if="testResult(index).ttfbMs">TTFB: {{ testResult(index).ttfbMs }} ms</div>
|
||||||
|
<div v-if="testResult(index).tlsMs">TLS: {{ testResult(index).tlsMs }} ms</div>
|
||||||
|
<div v-if="testResult(index).connectMs">Connect: {{ testResult(index).connectMs }} ms</div>
|
||||||
|
<div v-if="testResult(index).dnsMs">DNS: {{ testResult(index).dnsMs }} ms</div>
|
||||||
|
<div v-if="testResult(index).statusCode">HTTP {{ testResult(index).statusCode }}</div>
|
||||||
|
<div v-for="ep in testResult(index).endpoints || []" :key="ep.address" class="endpoint-row">
|
||||||
|
<span :class="ep.success ? 'dot-ok' : 'dot-fail'">●</span>
|
||||||
|
<span class="ep-addr">{{ ep.address }}</span>
|
||||||
|
<span class="ep-meta">{{ ep.success ? `${ep.delay} ms` : (ep.error || 'failed') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
|
||||||
|
<CheckCircleFilled v-if="testResult(index).success" />
|
||||||
|
<CloseCircleFilled v-else />
|
||||||
|
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
|
||||||
|
<span v-else>failed</span>
|
||||||
|
</span>
|
||||||
|
</a-popover>
|
||||||
<LoadingOutlined v-else-if="isTesting(index)" />
|
<LoadingOutlined v-else-if="isTesting(index)" />
|
||||||
<span v-else class="empty">—</span>
|
<span v-else class="empty">—</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'test'">
|
<template v-else-if="column.key === 'test'">
|
||||||
<a-tooltip :title="t('check')">
|
<a-tooltip :title="`${t('check')} (${testMode.toUpperCase()})`">
|
||||||
<a-button type="primary" shape="circle" :loading="isTesting(index)"
|
<a-button type="primary" shape="circle" :loading="isTesting(index)"
|
||||||
:disabled="isUntestable(record) || isTesting(index)" @click="emit('test', index)">
|
:disabled="isUntestable(record, testMode) || isTesting(index)" @click="emit('test', index, testMode)">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<ThunderboltOutlined />
|
<ThunderboltOutlined />
|
||||||
</template>
|
</template>
|
||||||
@@ -376,7 +442,7 @@ const rows = computed(() => {
|
|||||||
</a-table>
|
</a-table>
|
||||||
|
|
||||||
<OutboundFormModal v-model:open="modalOpen" :outbound="editingOutbound" :existing-tags="existingTags"
|
<OutboundFormModal v-model:open="modalOpen" :outbound="editingOutbound" :existing-tags="existingTags"
|
||||||
:inbound-tags="inboundTagOptions" @confirm="onConfirm" />
|
@confirm="onConfirm" />
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -386,6 +452,11 @@ const rows = computed(() => {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-right :global(.ant-space),
|
||||||
|
.header-actions :global(.ant-space) {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.card-empty {
|
.card-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
@@ -468,6 +539,14 @@ const rows = computed(() => {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.identity-cell {
|
.identity-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -532,3 +611,66 @@ const rows = computed(() => {
|
|||||||
color: #ff4d4f;
|
color: #ff4d4f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.outbound-test-popover .timing-breakdown {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .td-head {
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .td-head.ok {
|
||||||
|
color: #008771;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .td-head.fail {
|
||||||
|
color: #e04141;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .mode-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(22, 119, 255, 0.12);
|
||||||
|
color: #1677ff;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .endpoint-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .endpoint-row .ep-addr {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .endpoint-row .ep-meta {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .dot-ok {
|
||||||
|
color: #008771;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .dot-fail {
|
||||||
|
color: #e04141;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
ArrowUpOutlined,
|
ArrowUpOutlined,
|
||||||
ArrowDownOutlined,
|
ArrowDownOutlined,
|
||||||
|
HolderOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { Modal } from 'ant-design-vue';
|
import { Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
@@ -22,9 +23,11 @@ const { t } = useI18n();
|
|||||||
// "lead value + N more" pill per criterion (matches the legacy pill
|
// "lead value + N more" pill per criterion (matches the legacy pill
|
||||||
// layout); full lists surface via tooltip on hover.
|
// layout); full lists surface via tooltip on hover.
|
||||||
//
|
//
|
||||||
// Reorder uses up/down buttons in the action menu rather than the
|
// Reorder via Pointer Events on the grip icon — unified mouse +
|
||||||
// jQuery-Sortable drag handle the legacy panel used — same effect,
|
// touch + pen path so the same code works on desktop and mobile
|
||||||
// no extra dep. The mobile column layout drops source/network/
|
// (HTML5 drag doesn't fire from touch on iOS Safari, hence the
|
||||||
|
// switch). Up/down buttons in the action menu stay as a keyboard
|
||||||
|
// fallback. The mobile column layout drops source/network/
|
||||||
// destination criteria for readability.
|
// destination criteria for readability.
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -65,18 +68,32 @@ const editingRule = ref(null);
|
|||||||
const editingIndex = ref(null);
|
const editingIndex = ref(null);
|
||||||
|
|
||||||
const inboundTagOptions = computed(() => {
|
const inboundTagOptions = computed(() => {
|
||||||
const out = new Set();
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
function pushUnique(tag) {
|
||||||
|
if (!tag) return;
|
||||||
|
if (seen.has(tag)) return;
|
||||||
|
seen.add(tag);
|
||||||
|
out.push(tag);
|
||||||
|
}
|
||||||
|
|
||||||
for (const ib of props.templateSettings?.inbounds || []) {
|
for (const ib of props.templateSettings?.inbounds || []) {
|
||||||
if (ib.tag) out.add(ib.tag);
|
pushUnique(ib.tag);
|
||||||
|
}
|
||||||
|
for (const t of props.inboundTags || []) {
|
||||||
|
pushUnique(t);
|
||||||
}
|
}
|
||||||
for (const t of props.inboundTags || []) out.add(t);
|
|
||||||
for (const ob of props.templateSettings?.outbounds || []) {
|
for (const ob of props.templateSettings?.outbounds || []) {
|
||||||
const rt = ob?.reverse?.tag || ob?.settings?.reverse?.tag;
|
const rt = ob?.reverse?.tag || ob?.settings?.reverse?.tag || ob?.settings?.inboundTag;
|
||||||
if (rt) out.add(rt);
|
pushUnique(rt);
|
||||||
}
|
}
|
||||||
// dnsTag if DNS is configured.
|
pushUnique(props.templateSettings?.dns?.tag);
|
||||||
const dt = props.templateSettings?.dns?.tag;
|
|
||||||
if (dt) out.add(dt);
|
for (const s of props.templateSettings?.dns?.servers || []) {
|
||||||
|
if (typeof s === 'object' && s?.tag) pushUnique(s.tag);
|
||||||
|
}
|
||||||
|
|
||||||
return [...out];
|
return [...out];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,22 +165,91 @@ function moveDown(idx) {
|
|||||||
[rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]];
|
[rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const draggedIndex = ref(null);
|
||||||
|
const dropTargetIndex = ref(null);
|
||||||
|
let dragStartY = 0;
|
||||||
|
let dragMoved = false;
|
||||||
|
|
||||||
|
function onHandlePointerDown(idx, ev) {
|
||||||
|
if (ev.button != null && ev.button !== 0) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
draggedIndex.value = idx;
|
||||||
|
dropTargetIndex.value = idx;
|
||||||
|
dragStartY = ev.clientY;
|
||||||
|
dragMoved = false;
|
||||||
|
document.addEventListener('pointermove', onDragPointerMove);
|
||||||
|
document.addEventListener('pointerup', onDragPointerUp);
|
||||||
|
document.addEventListener('pointercancel', onDragPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragPointerMove(ev) {
|
||||||
|
if (draggedIndex.value == null) return;
|
||||||
|
if (!dragMoved && Math.abs(ev.clientY - dragStartY) < 5) return;
|
||||||
|
dragMoved = true;
|
||||||
|
const el = document.elementFromPoint(ev.clientX, ev.clientY);
|
||||||
|
if (!el) return;
|
||||||
|
const target = el.closest('[data-row-key]');
|
||||||
|
if (!target) return;
|
||||||
|
const idx = Number(target.getAttribute('data-row-key'));
|
||||||
|
if (Number.isFinite(idx)) dropTargetIndex.value = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragPointerUp() {
|
||||||
|
document.removeEventListener('pointermove', onDragPointerMove);
|
||||||
|
document.removeEventListener('pointerup', onDragPointerUp);
|
||||||
|
document.removeEventListener('pointercancel', onDragPointerUp);
|
||||||
|
const from = draggedIndex.value;
|
||||||
|
const to = dropTargetIndex.value;
|
||||||
|
draggedIndex.value = null;
|
||||||
|
dropTargetIndex.value = null;
|
||||||
|
if (!dragMoved || from == null || to == null || from === to) return;
|
||||||
|
const rules = props.templateSettings.routing.rules;
|
||||||
|
const [moved] = rules.splice(from, 1);
|
||||||
|
rules.splice(to, 0, moved);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowProps(_record, index) {
|
||||||
|
const classes = [];
|
||||||
|
if (draggedIndex.value === index) classes.push('row-dragging');
|
||||||
|
if (dropTargetIndex.value === index && draggedIndex.value !== index) {
|
||||||
|
classes.push(index > draggedIndex.value ? 'drop-after' : 'drop-before');
|
||||||
|
}
|
||||||
|
return { class: classes.join(' ') };
|
||||||
|
}
|
||||||
|
|
||||||
// === Columns =========================================================
|
// === Columns =========================================================
|
||||||
// Computed so titles re-render after a locale swap.
|
// Computed so titles re-render after a locale swap.
|
||||||
const desktopColumns = computed(() => [
|
const desktopColumns = computed(() => [
|
||||||
{ title: '#', align: 'center', width: 70, key: 'action' },
|
{ title: '#', align: 'center', width: 100, key: 'action' },
|
||||||
{ title: 'Source', align: 'left', width: 180, key: 'source' },
|
{ title: 'Source', align: 'left', width: 180, key: 'source' },
|
||||||
{ title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
|
{ title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
|
||||||
{ title: 'Destination', align: 'left', key: 'destination' },
|
{ title: 'Destination', align: 'left', key: 'destination' },
|
||||||
{ title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' },
|
{ title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' },
|
||||||
{ title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'target' },
|
{ title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'outbound' },
|
||||||
|
{ title: t('pages.xray.Balancers'), align: 'left', width: 150, key: 'balancer' },
|
||||||
]);
|
]);
|
||||||
const mobileColumns = computed(() => [
|
const columns = computed(() => desktopColumns.value);
|
||||||
{ title: '#', align: 'center', width: 70, key: 'action' },
|
|
||||||
{ title: t('pages.xray.Inbounds'), align: 'left', key: 'inbound' },
|
function ruleCriteriaChips(rule) {
|
||||||
{ title: t('pages.xray.Outbounds'), align: 'left', width: 140, key: 'target' },
|
const chips = [];
|
||||||
]);
|
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
|
||||||
const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
|
if (rule.ip) chips.push({ label: 'IP', value: rule.ip });
|
||||||
|
if (rule.port) chips.push({ label: 'Port', value: rule.port });
|
||||||
|
if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP });
|
||||||
|
if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort });
|
||||||
|
if (rule.network) chips.push({ label: 'L4', value: rule.network });
|
||||||
|
if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol });
|
||||||
|
if (rule.user) chips.push({ label: 'User', value: rule.user });
|
||||||
|
if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute });
|
||||||
|
return chips;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipPreview(value) {
|
||||||
|
const parts = csv(value);
|
||||||
|
if (parts.length === 0) return '';
|
||||||
|
if (parts.length === 1) return parts[0];
|
||||||
|
return `${parts[0]} +${parts.length - 1}`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -175,34 +261,117 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||||||
{{ t('pages.xray.Routings') }}
|
{{ t('pages.xray.Routings') }}
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
|
<!-- Mobile: stacked cards. The desktop a-table doesn't fit on a
|
||||||
:scroll="isMobile ? {} : { x: 1000 }" size="small" class="routing-table">
|
phone (~520px of columns alone), so render each rule as a
|
||||||
|
compact card with the routing summary + criteria chips. -->
|
||||||
|
<div v-if="isMobile" class="rule-list">
|
||||||
|
<div v-for="(rule, index) in rows" :key="rule.key" class="rule-card" :class="{
|
||||||
|
'row-dragging': draggedIndex === index,
|
||||||
|
'drop-before': dropTargetIndex === index && draggedIndex != null && index < draggedIndex,
|
||||||
|
'drop-after': dropTargetIndex === index && draggedIndex != null && index > draggedIndex,
|
||||||
|
}" :data-row-key="index">
|
||||||
|
<div class="rule-card-head">
|
||||||
|
<HolderOutlined class="drag-handle" @pointerdown="onHandlePointerDown(index, $event)" />
|
||||||
|
<span class="rule-number">#{{ index + 1 }}</span>
|
||||||
|
<a-dropdown :trigger="['click']">
|
||||||
|
<a-button shape="circle" size="small">
|
||||||
|
<MoreOutlined />
|
||||||
|
</a-button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item @click="openEdit(index)">
|
||||||
|
<EditOutlined /> {{ t('edit') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
|
||||||
|
<ArrowUpOutlined />
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
|
||||||
|
<ArrowDownOutlined />
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item class="danger" @click="confirmDelete(index)">
|
||||||
|
<DeleteOutlined /> {{ t('delete') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rule-flow">
|
||||||
|
<div class="flow-side">
|
||||||
|
<span class="flow-label">{{ t('pages.xray.Inbounds') }}</span>
|
||||||
|
<a-tag v-if="rule.inboundTag" color="blue" class="flow-tag">
|
||||||
|
{{ chipPreview(rule.inboundTag) }}
|
||||||
|
</a-tag>
|
||||||
|
<span v-else class="criterion-empty">any</span>
|
||||||
|
</div>
|
||||||
|
<span class="flow-arrow">→</span>
|
||||||
|
<div class="flow-side flow-side-target">
|
||||||
|
<span class="flow-label">{{
|
||||||
|
rule.balancerTag ? (t('pages.xray.balancer') || 'Balancer') : t('pages.xray.Outbounds')
|
||||||
|
}}</span>
|
||||||
|
<a-tag v-if="rule.outboundTag" color="green" class="flow-tag">
|
||||||
|
<ExportOutlined /> {{ rule.outboundTag }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else-if="rule.balancerTag" color="purple" class="flow-tag">
|
||||||
|
<ClusterOutlined /> {{ rule.balancerTag }}
|
||||||
|
</a-tag>
|
||||||
|
<span v-else class="criterion-empty">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ruleCriteriaChips(rule).length" class="rule-criteria">
|
||||||
|
<a-tooltip v-for="chip in ruleCriteriaChips(rule)" :key="chip.label" :title="`${chip.label}: ${chip.value}`">
|
||||||
|
<span class="criterion-chip">
|
||||||
|
<span class="criterion-chip-label">{{ chip.label }}</span>
|
||||||
|
<span class="criterion-chip-value">{{ chipPreview(chip.value) }}</span>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!rows.length" class="rule-empty">—</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-table v-else :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
|
||||||
|
:scroll="{ x: 1150 }" size="small" class="routing-table" :custom-row="rowProps">
|
||||||
<template #bodyCell="{ column, record, index }">
|
<template #bodyCell="{ column, record, index }">
|
||||||
<!-- ============== # / actions ============== -->
|
<!-- ============== # / actions ============== -->
|
||||||
<template v-if="column.key === 'action'">
|
<template v-if="column.key === 'action'">
|
||||||
<div class="action-cell">
|
<div class="action-cell">
|
||||||
|
<HolderOutlined class="drag-handle" :title="t('drag') || 'Drag to reorder'"
|
||||||
|
@pointerdown="onHandlePointerDown(index, $event)" />
|
||||||
<span class="row-index">{{ index + 1 }}</span>
|
<span class="row-index">{{ index + 1 }}</span>
|
||||||
<a-dropdown :trigger="['click']">
|
|
||||||
<a-button shape="circle" size="small">
|
<div :class="!isMobile ? 'action-buttons' : ''">
|
||||||
<MoreOutlined />
|
<a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
|
||||||
|
<template #icon>
|
||||||
|
<EditOutlined />
|
||||||
|
</template>
|
||||||
</a-button>
|
</a-button>
|
||||||
<template #overlay>
|
|
||||||
<a-menu>
|
<a-dropdown :trigger="['click']">
|
||||||
<a-menu-item @click="openEdit(index)">
|
<a-button shape="circle" size="small">
|
||||||
<EditOutlined /> {{ t('edit') }}
|
<template #icon>
|
||||||
</a-menu-item>
|
<MoreOutlined />
|
||||||
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
|
</template>
|
||||||
<ArrowUpOutlined />
|
</a-button>
|
||||||
</a-menu-item>
|
<template #overlay>
|
||||||
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
|
<a-menu>
|
||||||
<ArrowDownOutlined />
|
<a-menu-item v-if="isMobile" @click="openEdit(index)">
|
||||||
</a-menu-item>
|
<EditOutlined /> {{ t('edit') }}
|
||||||
<a-menu-item class="danger" @click="confirmDelete(index)">
|
</a-menu-item>
|
||||||
<DeleteOutlined /> {{ t('delete') }}
|
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
|
||||||
</a-menu-item>
|
<ArrowUpOutlined />
|
||||||
</a-menu>
|
</a-menu-item>
|
||||||
</template>
|
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
|
||||||
</a-dropdown>
|
<ArrowDownOutlined />
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item class="danger" @click="confirmDelete(index)">
|
||||||
|
<DeleteOutlined /> {{ t('delete') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -214,7 +383,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||||||
<span class="criterion-label">IP</span>
|
<span class="criterion-label">IP</span>
|
||||||
<span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span>
|
<span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span>
|
||||||
<span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1
|
<span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1
|
||||||
}}</span>
|
}}</span>
|
||||||
</span>
|
</span>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
|
<a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
|
||||||
@@ -245,7 +414,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||||||
<span class="criterion-label">L4</span>
|
<span class="criterion-label">L4</span>
|
||||||
<span class="criterion-value">{{ csv(record.network)[0] }}</span>
|
<span class="criterion-value">{{ csv(record.network)[0] }}</span>
|
||||||
<span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1
|
<span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1
|
||||||
}}</span>
|
}}</span>
|
||||||
</span>
|
</span>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
|
<a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
|
||||||
@@ -253,7 +422,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||||||
<span class="criterion-label">Protocol</span>
|
<span class="criterion-label">Protocol</span>
|
||||||
<span class="criterion-value">{{ csv(record.protocol)[0] }}</span>
|
<span class="criterion-value">{{ csv(record.protocol)[0] }}</span>
|
||||||
<span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1
|
<span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1
|
||||||
}}</span>
|
}}</span>
|
||||||
</span>
|
</span>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
|
<a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
|
||||||
@@ -281,7 +450,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||||||
<span class="criterion-label">Domain</span>
|
<span class="criterion-label">Domain</span>
|
||||||
<span class="criterion-value">{{ csv(record.domain)[0] }}</span>
|
<span class="criterion-value">{{ csv(record.domain)[0] }}</span>
|
||||||
<span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1
|
<span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1
|
||||||
}}</span>
|
}}</span>
|
||||||
</span>
|
</span>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
|
<a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
|
||||||
@@ -289,7 +458,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||||||
<span class="criterion-label">Port</span>
|
<span class="criterion-label">Port</span>
|
||||||
<span class="criterion-value">{{ csv(record.port)[0] }}</span>
|
<span class="criterion-value">{{ csv(record.port)[0] }}</span>
|
||||||
<span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1
|
<span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1
|
||||||
}}</span>
|
}}</span>
|
||||||
</span>
|
</span>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty">—</span>
|
<span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty">—</span>
|
||||||
@@ -312,25 +481,32 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||||||
<span class="criterion-label">User</span>
|
<span class="criterion-label">User</span>
|
||||||
<span class="criterion-value">{{ csv(record.user)[0] }}</span>
|
<span class="criterion-value">{{ csv(record.user)[0] }}</span>
|
||||||
<span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1
|
<span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1
|
||||||
}}</span>
|
}}</span>
|
||||||
</span>
|
</span>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<span v-if="!record.inboundTag && !record.user" class="criterion-empty">—</span>
|
<span v-if="!record.inboundTag && !record.user" class="criterion-empty">—</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ============== Outbound / balancer target ============== -->
|
<!-- ============== Outbound ============== -->
|
||||||
<template v-else-if="column.key === 'target'">
|
<template v-else-if="column.key === 'outbound'">
|
||||||
<div class="target-cell">
|
<div class="target-cell">
|
||||||
<div v-if="record.outboundTag" class="target-row">
|
<div v-if="record.outboundTag" class="target-row">
|
||||||
<ExportOutlined class="target-icon" />
|
<ExportOutlined class="target-icon" />
|
||||||
<a-tag color="green">{{ record.outboundTag }}</a-tag>
|
<a-tag color="green">{{ record.outboundTag }}</a-tag>
|
||||||
</div>
|
</div>
|
||||||
|
<span v-else class="criterion-empty">—</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ============== Balancer ============== -->
|
||||||
|
<template v-else-if="column.key === 'balancer'">
|
||||||
|
<div class="target-cell">
|
||||||
<div v-if="record.balancerTag" class="target-row">
|
<div v-if="record.balancerTag" class="target-row">
|
||||||
<ClusterOutlined class="target-icon" />
|
<ClusterOutlined class="target-icon" />
|
||||||
<a-tag color="purple">{{ record.balancerTag }}</a-tag>
|
<a-tag color="purple">{{ record.balancerTag }}</a-tag>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="!record.outboundTag && !record.balancerTag" class="criterion-empty">—</span>
|
<span v-else class="criterion-empty">—</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -348,6 +524,36 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 0.35;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px;
|
||||||
|
margin: -4px;
|
||||||
|
touch-action: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.row-dragging) {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.drop-before > td) {
|
||||||
|
box-shadow: inset 0 2px 0 0 #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.drop-after > td) {
|
||||||
|
box-shadow: inset 0 -2px 0 0 #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
.row-index {
|
.row-index {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
@@ -355,6 +561,14 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.criterion-flow {
|
.criterion-flow {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -415,4 +629,136 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||||||
.danger {
|
.danger {
|
||||||
color: #ff4d4f;
|
color: #ff4d4f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Mobile card list ====================================== */
|
||||||
|
.rule-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: opacity 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card.row-dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card.drop-before {
|
||||||
|
box-shadow: inset 0 2px 0 0 #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card.drop-after {
|
||||||
|
box-shadow: inset 0 -2px 0 0 #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-number {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.75;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-flow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-side {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-side-target {
|
||||||
|
align-items: flex-end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-tag {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-arrow {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.45;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-criteria {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px dashed rgba(128, 128, 128, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.criterion-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(128, 128, 128, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.criterion-chip-label {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.criterion-chip-value {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .rule-card {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .criterion-chip {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const loading = ref(false);
|
|||||||
const warpData = ref(null);
|
const warpData = ref(null);
|
||||||
const warpConfig = ref(null);
|
const warpConfig = ref(null);
|
||||||
const warpPlus = ref('');
|
const warpPlus = ref('');
|
||||||
|
const licenseError = ref('');
|
||||||
// Held in memory so the parent's add/reset handlers receive the same
|
// Held in memory so the parent's add/reset handlers receive the same
|
||||||
// object the modal computed from getConfig().
|
// object the modal computed from getConfig().
|
||||||
const stagedOutbound = ref(null);
|
const stagedOutbound = ref(null);
|
||||||
@@ -41,6 +42,7 @@ watch(() => props.open, (next) => {
|
|||||||
if (!next) return;
|
if (!next) return;
|
||||||
warpConfig.value = null;
|
warpConfig.value = null;
|
||||||
stagedOutbound.value = null;
|
stagedOutbound.value = null;
|
||||||
|
licenseError.value = '';
|
||||||
fetchData();
|
fetchData();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,12 +91,15 @@ async function getConfig() {
|
|||||||
async function updateLicense() {
|
async function updateLicense() {
|
||||||
if (warpPlus.value.length < 26) return;
|
if (warpPlus.value.length < 26) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
licenseError.value = '';
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/xray/warp/license', { license: warpPlus.value });
|
const msg = await HttpUtil.post('/panel/xray/warp/license', { license: warpPlus.value });
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
warpData.value = JSON.parse(msg.obj);
|
warpData.value = JSON.parse(msg.obj);
|
||||||
warpConfig.value = null;
|
warpConfig.value = null;
|
||||||
warpPlus.value = '';
|
warpPlus.value = '';
|
||||||
|
} else {
|
||||||
|
licenseError.value = msg?.msg || 'Failed to set WARP license.';
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -233,9 +238,12 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
|
|||||||
<a-collapse-panel header="WARP / WARP+ license key">
|
<a-collapse-panel header="WARP / WARP+ license key">
|
||||||
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 14 } }">
|
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 14 } }">
|
||||||
<a-form-item label="Key">
|
<a-form-item label="Key">
|
||||||
<a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" />
|
<a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" @update:value="licenseError = ''" />
|
||||||
<a-button type="primary" class="mt-8" :disabled="warpPlus.length < 26" :loading="loading"
|
<div class="license-actions mt-8">
|
||||||
@click="updateLicense">Update</a-button>
|
<a-button type="primary" :disabled="warpPlus.length < 26" :loading="loading"
|
||||||
|
@click="updateLicense">Update</a-button>
|
||||||
|
<a-alert v-if="licenseError" :message="licenseError" type="error" show-icon class="license-error" />
|
||||||
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
@@ -358,4 +366,16 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
|
|||||||
.ml-8 {
|
.ml-8 {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.license-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-error {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Modal, message } from 'ant-design-vue';
|
import { Modal, message } from 'ant-design-vue';
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,7 @@ import BalancersTab from './BalancersTab.vue';
|
|||||||
import DnsTab from './DnsTab.vue';
|
import DnsTab from './DnsTab.vue';
|
||||||
import WarpModal from './WarpModal.vue';
|
import WarpModal from './WarpModal.vue';
|
||||||
import NordModal from './NordModal.vue';
|
import NordModal from './NordModal.vue';
|
||||||
|
import JsonEditor from '@/components/JsonEditor.vue';
|
||||||
import { useXraySetting } from './useXraySetting.js';
|
import { useXraySetting } from './useXraySetting.js';
|
||||||
import { useWebSocket } from '@/composables/useWebSocket.js';
|
import { useWebSocket } from '@/composables/useWebSocket.js';
|
||||||
|
|
||||||
@@ -40,21 +41,26 @@ const {
|
|||||||
restartResult,
|
restartResult,
|
||||||
outboundsTraffic,
|
outboundsTraffic,
|
||||||
outboundTestStates,
|
outboundTestStates,
|
||||||
|
testingAll,
|
||||||
fetchAll,
|
fetchAll,
|
||||||
resetOutboundsTraffic,
|
resetOutboundsTraffic,
|
||||||
testOutbound,
|
testOutbound,
|
||||||
|
testAllOutbounds,
|
||||||
saveAll,
|
saveAll,
|
||||||
resetToDefault,
|
resetToDefault,
|
||||||
restartXray,
|
restartXray,
|
||||||
applyOutboundsEvent,
|
applyOutboundsEvent,
|
||||||
} = useXraySetting();
|
} = useXraySetting();
|
||||||
|
|
||||||
// Live outbounds traffic — pushed by xray_traffic_job every ~10s.
|
|
||||||
useWebSocket({ outbounds: applyOutboundsEvent });
|
useWebSocket({ outbounds: applyOutboundsEvent });
|
||||||
|
|
||||||
async function onTestOutbound(idx) {
|
async function onTestOutbound(idx, mode = 'tcp') {
|
||||||
const outbound = templateSettings.value?.outbounds?.[idx];
|
const outbound = templateSettings.value?.outbounds?.[idx];
|
||||||
if (outbound) await testOutbound(idx, outbound);
|
if (outbound) await testOutbound(idx, outbound, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTestAllOutbounds(mode = 'tcp') {
|
||||||
|
await testAllOutbounds(mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDeleteOutbound(idx) {
|
function onDeleteOutbound(idx) {
|
||||||
@@ -181,12 +187,9 @@ function onRemoveRoutingRules({ prefix }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// `message` is used by some of the in-progress UX flows (kept around
|
|
||||||
// because future provisioning errors will surface through it).
|
|
||||||
void message;
|
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
|
|
||||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
const requestUri = window.location.pathname;
|
const requestUri = window.location.pathname;
|
||||||
|
|
||||||
// See SettingsPage scrollTarget — wrap so `document` is in scope.
|
// See SettingsPage scrollTarget — wrap so `document` is in scope.
|
||||||
@@ -203,6 +206,51 @@ function confirmRestart() {
|
|||||||
onOk: () => restartXray(),
|
onOk: () => restartXray(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tabKeys = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
|
||||||
|
const slugByKey = {
|
||||||
|
'tpl-basic': 'basic',
|
||||||
|
'tpl-routing': 'routing',
|
||||||
|
'tpl-outbound': 'outbound',
|
||||||
|
'tpl-balancer': 'balancer',
|
||||||
|
'tpl-dns': 'dns',
|
||||||
|
'tpl-advanced': 'advanced',
|
||||||
|
};
|
||||||
|
const keyBySlug = Object.fromEntries(Object.entries(slugByKey).map(([k, v]) => [v, k]));
|
||||||
|
|
||||||
|
const activeTabKey = ref(keyBySlug[window.location.hash.slice(1)] || tabKeys[0]);
|
||||||
|
|
||||||
|
function onTabChange(key) {
|
||||||
|
activeTabKey.value = key;
|
||||||
|
const slug = slugByKey[key];
|
||||||
|
if (slug && window.location.hash !== `#${slug}`) {
|
||||||
|
history.replaceState(null, '', `#${slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSaveAll() {
|
||||||
|
try {
|
||||||
|
JSON.parse(xraySetting.value);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(`Advanced JSON: ${e.message}`);
|
||||||
|
activeTabKey.value = 'tpl-advanced';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTabFromHash() {
|
||||||
|
const key = keyBySlug[window.location.hash.slice(1)];
|
||||||
|
if (key) activeTabKey.value = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('hashchange', syncTabFromHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('hashchange', syncTabFromHash);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -229,7 +277,7 @@ function confirmRestart() {
|
|||||||
<a-row class="header-row">
|
<a-row class="header-row">
|
||||||
<a-col :xs="24" :sm="14" class="header-actions">
|
<a-col :xs="24" :sm="14" class="header-actions">
|
||||||
<a-space direction="horizontal">
|
<a-space direction="horizontal">
|
||||||
<a-button type="primary" :disabled="saveDisabled" @click="saveAll">
|
<a-button type="primary" :disabled="saveDisabled" @click="onSaveAll">
|
||||||
{{ t('pages.xray.save') }}
|
{{ t('pages.xray.save') }}
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="primary" danger :disabled="!saveDisabled" @click="confirmRestart">
|
<a-button type="primary" danger :disabled="!saveDisabled" @click="confirmRestart">
|
||||||
@@ -254,10 +302,13 @@ function confirmRestart() {
|
|||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<a-tabs default-active-key="tpl-basic">
|
<a-tabs :active-key="activeTabKey" :class="{ 'icons-only': isMobile }" @change="onTabChange">
|
||||||
<a-tab-pane key="tpl-basic" class="tab-pane">
|
<a-tab-pane key="tpl-basic" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span>
|
<a-tooltip :title="isMobile ? t('pages.xray.basicTemplate') : null">
|
||||||
|
<SettingOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.xray.basicTemplate') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<BasicsTab :template-settings="templateSettings" :outbound-test-url="outboundTestUrl"
|
<BasicsTab :template-settings="templateSettings" :outbound-test-url="outboundTestUrl"
|
||||||
:warp-exist="warpExist" :nord-exist="nordExist"
|
:warp-exist="warpExist" :nord-exist="nordExist"
|
||||||
@@ -267,7 +318,10 @@ function confirmRestart() {
|
|||||||
|
|
||||||
<a-tab-pane key="tpl-routing" class="tab-pane">
|
<a-tab-pane key="tpl-routing" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<SwapOutlined /> <span>{{ t('pages.xray.Routings') }}</span>
|
<a-tooltip :title="isMobile ? t('pages.xray.Routings') : null">
|
||||||
|
<SwapOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.xray.Routings') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<RoutingTab :template-settings="templateSettings" :inbound-tags="inboundTags"
|
<RoutingTab :template-settings="templateSettings" :inbound-tags="inboundTags"
|
||||||
:client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
|
:client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
|
||||||
@@ -275,31 +329,46 @@ function confirmRestart() {
|
|||||||
|
|
||||||
<a-tab-pane key="tpl-outbound" class="tab-pane">
|
<a-tab-pane key="tpl-outbound" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<UploadOutlined /> <span>{{ t('pages.xray.Outbounds') }}</span>
|
<a-tooltip :title="isMobile ? t('pages.xray.Outbounds') : null">
|
||||||
|
<UploadOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.xray.Outbounds') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
|
<OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
|
||||||
:outbound-test-states="outboundTestStates" :inbound-tags="inboundTags" :is-mobile="isMobile"
|
:outbound-test-states="outboundTestStates" :testing-all="testingAll"
|
||||||
@reset-traffic="resetOutboundsTraffic" @test="onTestOutbound" @delete="onDeleteOutbound"
|
:inbound-tags="inboundTags" :is-mobile="isMobile"
|
||||||
|
@reset-traffic="resetOutboundsTraffic" @test="onTestOutbound"
|
||||||
|
@test-all="onTestAllOutbounds" @delete="onDeleteOutbound"
|
||||||
@show-warp="showWarp" @show-nord="showNord" />
|
@show-warp="showWarp" @show-nord="showNord" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
||||||
<a-tab-pane key="tpl-balancer" class="tab-pane">
|
<a-tab-pane key="tpl-balancer" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<ClusterOutlined /> <span>{{ t('pages.xray.Balancers') }}</span>
|
<a-tooltip :title="isMobile ? t('pages.xray.Balancers') : null">
|
||||||
|
<ClusterOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.xray.Balancers') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<BalancersTab :template-settings="templateSettings" :client-reverse-tags="clientReverseTags" />
|
<BalancersTab :template-settings="templateSettings"
|
||||||
|
:client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
||||||
<a-tab-pane key="tpl-dns" class="tab-pane">
|
<a-tab-pane key="tpl-dns" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<DatabaseOutlined /> <span>DNS</span>
|
<a-tooltip :title="isMobile ? 'DNS' : null">
|
||||||
|
<DatabaseOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">DNS</span>
|
||||||
</template>
|
</template>
|
||||||
<DnsTab :template-settings="templateSettings" />
|
<DnsTab :template-settings="templateSettings" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
||||||
<a-tab-pane key="tpl-advanced" class="tab-pane">
|
<a-tab-pane key="tpl-advanced" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<CodeOutlined /> <span>{{ t('pages.xray.advancedTemplate') }}</span>
|
<a-tooltip :title="isMobile ? t('pages.xray.advancedTemplate') : null">
|
||||||
|
<CodeOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.xray.advancedTemplate') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<a-list-item-meta :title="t('pages.xray.Template')" :description="t('pages.xray.TemplateDesc')" />
|
<a-list-item-meta :title="t('pages.xray.Template')" :description="t('pages.xray.TemplateDesc')" />
|
||||||
<a-radio-group v-model:value="advSettings" button-style="solid"
|
<a-radio-group v-model:value="advSettings" button-style="solid"
|
||||||
@@ -309,8 +378,7 @@ function confirmRestart() {
|
|||||||
<a-radio-button value="outboundSettings">{{ t('pages.xray.Outbounds') }}</a-radio-button>
|
<a-radio-button value="outboundSettings">{{ t('pages.xray.Outbounds') }}</a-radio-button>
|
||||||
<a-radio-button value="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
|
<a-radio-button value="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
<a-textarea v-model:value="advancedText" :auto-size="{ minRows: 18, maxRows: 40 }"
|
<JsonEditor v-model:value="advancedText" min-height="420px" max-height="720px" />
|
||||||
spellcheck="false" class="json-editor" />
|
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -339,8 +407,8 @@ function confirmRestart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.xray-page.is-dark {
|
.xray-page.is-dark {
|
||||||
--bg-page: #0a1222;
|
--bg-page: #1e1e1e;
|
||||||
--bg-card: #151f31;
|
--bg-card: #252526;
|
||||||
}
|
}
|
||||||
|
|
||||||
.xray-page.is-dark.is-ultra {
|
.xray-page.is-dark.is-ultra {
|
||||||
@@ -397,8 +465,32 @@ function confirmRestart() {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-editor {
|
.icons-only :deep(.ant-tabs-nav) {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
margin-bottom: 8px;
|
||||||
font-size: 12px;
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-wrap) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-list) {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-tab) {
|
||||||
|
flex: 1 1 0;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-tab .anticon) {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-operations) {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,50 +1,24 @@
|
|||||||
// Drives the xray page's fetch / dirty / save lifecycle. The Go side
|
|
||||||
// returns the live xraySetting (the full JSON config), the inboundTags
|
|
||||||
// list, and a few sidecar values (clientReverseTags, outboundTestUrl)
|
|
||||||
// the structured tabs need. We keep the JSON as a string here — pretty-
|
|
||||||
// printed for the textarea; tabs that want a parsed view can JSON.parse
|
|
||||||
// it themselves.
|
|
||||||
|
|
||||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||||
|
|
||||||
const DIRTY_POLL_MS = 1000;
|
const DIRTY_POLL_MS = 1000;
|
||||||
|
|
||||||
// Hoists the parsed `templateSettings` alongside the JSON string so
|
|
||||||
// structured tabs (Basics/Routing/Outbounds/etc.) can mutate fields
|
|
||||||
// directly while the Advanced (JSON) tab edits the same data as text.
|
|
||||||
// We keep both in sync with two cooperating watches:
|
|
||||||
// • mutating templateSettings re-stringifies into xraySetting;
|
|
||||||
// • editing the JSON text re-parses into templateSettings (only on
|
|
||||||
// valid JSON — invalid edits leave templateSettings untouched
|
|
||||||
// so the structured tabs don't blow up while the user types).
|
|
||||||
let syncing = false;
|
let syncing = false;
|
||||||
|
|
||||||
export function useXraySetting() {
|
export function useXraySetting() {
|
||||||
const fetched = ref(false);
|
const fetched = ref(false);
|
||||||
const spinning = ref(false);
|
const spinning = ref(false);
|
||||||
const saveDisabled = ref(true);
|
const saveDisabled = ref(true);
|
||||||
// Holds a user-facing message when fetchAll fails; lets the page
|
|
||||||
// render an error UI instead of an endless spinner.
|
|
||||||
const fetchError = ref('');
|
const fetchError = ref('');
|
||||||
|
|
||||||
const xraySetting = ref('');
|
const xraySetting = ref('');
|
||||||
const oldXraySetting = ref('');
|
const oldXraySetting = ref('');
|
||||||
|
|
||||||
// Parsed mirror — null until first successful fetch / parse.
|
|
||||||
const templateSettings = ref(null);
|
const templateSettings = ref(null);
|
||||||
|
|
||||||
const outboundTestUrl = ref('https://www.google.com/generate_204');
|
const outboundTestUrl = ref('https://www.google.com/generate_204');
|
||||||
const oldOutboundTestUrl = ref('');
|
const oldOutboundTestUrl = ref('');
|
||||||
|
|
||||||
const inboundTags = ref([]);
|
const inboundTags = ref([]);
|
||||||
const clientReverseTags = ref([]);
|
const clientReverseTags = ref([]);
|
||||||
const restartResult = ref('');
|
const restartResult = ref('');
|
||||||
|
|
||||||
// Outbounds tab data — traffic stats + per-row test state. Test
|
|
||||||
// states are keyed by outbound index (sparse object), each entry
|
|
||||||
// is `{ testing, result }` where result is the wire response from
|
|
||||||
// /panel/xray/testOutbound or null while the test is in flight.
|
|
||||||
const outboundsTraffic = ref([]);
|
const outboundsTraffic = ref([]);
|
||||||
const outboundTestStates = ref({});
|
const outboundTestStates = ref({});
|
||||||
|
|
||||||
@@ -53,7 +27,6 @@ export function useXraySetting() {
|
|||||||
const msg = await HttpUtil.post('/panel/xray/');
|
const msg = await HttpUtil.post('/panel/xray/');
|
||||||
if (!msg?.success) {
|
if (!msg?.success) {
|
||||||
fetchError.value = msg?.msg || 'Failed to load xray config';
|
fetchError.value = msg?.msg || 'Failed to load xray config';
|
||||||
// Mark as fetched so the spinner clears and the error UI renders.
|
|
||||||
fetched.value = true;
|
fetched.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -79,8 +52,7 @@ export function useXraySetting() {
|
|||||||
saveDisabled.value = true;
|
saveDisabled.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Structured tabs mutate templateSettings deeply. Re-stringify on
|
|
||||||
// change so the Advanced JSON view + the dirty-poll see the edits.
|
|
||||||
watch(
|
watch(
|
||||||
templateSettings,
|
templateSettings,
|
||||||
(next) => {
|
(next) => {
|
||||||
@@ -95,8 +67,6 @@ export function useXraySetting() {
|
|||||||
{ deep: true },
|
{ deep: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Advanced JSON edits — only refresh templateSettings when the text
|
|
||||||
// parses, so structured tabs stay readable mid-edit.
|
|
||||||
watch(xraySetting, (next) => {
|
watch(xraySetting, (next) => {
|
||||||
if (syncing) return;
|
if (syncing) return;
|
||||||
try {
|
try {
|
||||||
@@ -133,21 +103,19 @@ export function useXraySetting() {
|
|||||||
if (msg?.success) await fetchOutboundsTraffic();
|
if (msg?.success) await fetchOutboundsTraffic();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merges a WebSocket `outbounds` event into outboundsTraffic in place.
|
|
||||||
// The xray traffic job pushes the full snapshot every ~10s so the user
|
|
||||||
// doesn't have to click the (now-removed) refresh button.
|
|
||||||
function applyOutboundsEvent(payload) {
|
function applyOutboundsEvent(payload) {
|
||||||
if (Array.isArray(payload)) outboundsTraffic.value = payload;
|
if (Array.isArray(payload)) outboundsTraffic.value = payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testOutbound(index, outbound) {
|
async function testOutbound(index, outbound, mode = 'tcp') {
|
||||||
if (!outbound) return null;
|
if (!outbound) return null;
|
||||||
if (!outboundTestStates.value[index]) outboundTestStates.value[index] = {};
|
if (!outboundTestStates.value[index]) outboundTestStates.value[index] = {};
|
||||||
outboundTestStates.value[index] = { testing: true, result: null };
|
outboundTestStates.value[index] = { testing: true, result: null, mode };
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/xray/testOutbound', {
|
const msg = await HttpUtil.post('/panel/xray/testOutbound', {
|
||||||
outbound: JSON.stringify(outbound),
|
outbound: JSON.stringify(outbound),
|
||||||
allOutbounds: JSON.stringify(templateSettings.value?.outbounds || []),
|
allOutbounds: JSON.stringify(templateSettings.value?.outbounds || []),
|
||||||
|
mode,
|
||||||
});
|
});
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
outboundTestStates.value[index] = { testing: false, result: msg.obj };
|
outboundTestStates.value[index] = { testing: false, result: msg.obj };
|
||||||
@@ -155,24 +123,53 @@ export function useXraySetting() {
|
|||||||
}
|
}
|
||||||
outboundTestStates.value[index] = {
|
outboundTestStates.value[index] = {
|
||||||
testing: false,
|
testing: false,
|
||||||
result: { success: false, error: msg?.msg || 'Unknown error' },
|
result: { success: false, error: msg?.msg || 'Unknown error', mode },
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
outboundTestStates.value[index] = {
|
outboundTestStates.value[index] = {
|
||||||
testing: false,
|
testing: false,
|
||||||
result: { success: false, error: String(e) },
|
result: { success: false, error: String(e), mode },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const testingAll = ref(false);
|
||||||
|
async function testAllOutbounds(mode = 'tcp') {
|
||||||
|
const list = templateSettings.value?.outbounds || [];
|
||||||
|
if (list.length === 0 || testingAll.value) return;
|
||||||
|
testingAll.value = true;
|
||||||
|
try {
|
||||||
|
const concurrency = mode === 'tcp' ? 8 : 1;
|
||||||
|
const queue = list
|
||||||
|
.map((ob, i) => ({ index: i, outbound: ob }))
|
||||||
|
.filter(({ outbound }) => {
|
||||||
|
const tag = outbound?.tag;
|
||||||
|
const proto = outbound?.protocol;
|
||||||
|
if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return false;
|
||||||
|
if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
async function worker() {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const item = queue.shift();
|
||||||
|
if (!item) break;
|
||||||
|
await testOutbound(item.index, item.outbound, mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker());
|
||||||
|
await Promise.all(workers);
|
||||||
|
} finally {
|
||||||
|
testingAll.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resetToDefault() {
|
async function resetToDefault() {
|
||||||
spinning.value = true;
|
spinning.value = true;
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
|
const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
// Mutate templateSettings — the watch above re-stringifies into
|
|
||||||
// xraySetting so the Advanced JSON tab and dirty-poll see it.
|
|
||||||
templateSettings.value = JSON.parse(JSON.stringify(msg.obj));
|
templateSettings.value = JSON.parse(JSON.stringify(msg.obj));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -234,11 +231,13 @@ export function useXraySetting() {
|
|||||||
restartResult,
|
restartResult,
|
||||||
outboundsTraffic,
|
outboundsTraffic,
|
||||||
outboundTestStates,
|
outboundTestStates,
|
||||||
|
testingAll,
|
||||||
fetchAll,
|
fetchAll,
|
||||||
fetchOutboundsTraffic,
|
fetchOutboundsTraffic,
|
||||||
resetOutboundsTraffic,
|
resetOutboundsTraffic,
|
||||||
applyOutboundsEvent,
|
applyOutboundsEvent,
|
||||||
testOutbound,
|
testOutbound,
|
||||||
|
testAllOutbounds,
|
||||||
saveAll,
|
saveAll,
|
||||||
resetToDefault,
|
resetToDefault,
|
||||||
restartXray,
|
restartXray,
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ export class HttpUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyDocumentTitle() {
|
||||||
|
const host = window.location.hostname;
|
||||||
|
if (!host) return;
|
||||||
|
const current = document.title.trim();
|
||||||
|
document.title = current ? `${host} - ${current}` : host;
|
||||||
|
}
|
||||||
|
|
||||||
export class PromiseUtil {
|
export class PromiseUtil {
|
||||||
static async sleep(timeout) {
|
static async sleep(timeout) {
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const BASE_MIGRATED_ROUTES = {
|
|||||||
'panel/xray/': '/xray.html',
|
'panel/xray/': '/xray.html',
|
||||||
'panel/nodes': '/nodes.html',
|
'panel/nodes': '/nodes.html',
|
||||||
'panel/nodes/': '/nodes.html',
|
'panel/nodes/': '/nodes.html',
|
||||||
|
'panel/api-docs': '/api-docs.html',
|
||||||
|
'panel/api-docs/': '/api-docs.html',
|
||||||
};
|
};
|
||||||
|
|
||||||
let cachedBasePath = '/';
|
let cachedBasePath = '/';
|
||||||
@@ -57,7 +59,7 @@ function refreshBasePath() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// `apply: 'serve'` keeps the injection out of `vite build` — dist.go
|
// `apply: 'serve'` keeps the injection out of `vite build` — dist.go
|
||||||
// already injects __X_UI_BASE_PATH__ at runtime in production.
|
// already injects webBasePath at runtime in production.
|
||||||
function injectBasePathPlugin() {
|
function injectBasePathPlugin() {
|
||||||
return {
|
return {
|
||||||
name: 'xui-inject-base-path',
|
name: 'xui-inject-base-path',
|
||||||
@@ -65,7 +67,7 @@ function injectBasePathPlugin() {
|
|||||||
transformIndexHtml(html) {
|
transformIndexHtml(html) {
|
||||||
const basePath = refreshBasePath();
|
const basePath = refreshBasePath();
|
||||||
const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
const tag = `<script>window.__X_UI_BASE_PATH__="${escaped}";</script>`;
|
const tag = `<script>window.X_UI_BASE_PATH="${escaped}";</script>`;
|
||||||
return html.replace('</head>', `${tag}</head>`);
|
return html.replace('</head>', `${tag}</head>`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -150,6 +152,7 @@ export default defineConfig({
|
|||||||
inbounds: path.resolve(__dirname, 'inbounds.html'),
|
inbounds: path.resolve(__dirname, 'inbounds.html'),
|
||||||
xray: path.resolve(__dirname, 'xray.html'),
|
xray: path.resolve(__dirname, 'xray.html'),
|
||||||
nodes: path.resolve(__dirname, 'nodes.html'),
|
nodes: path.resolve(__dirname, 'nodes.html'),
|
||||||
|
apiDocs: path.resolve(__dirname, 'api-docs.html'),
|
||||||
subpage: path.resolve(__dirname, 'subpage.html'),
|
subpage: path.resolve(__dirname, 'subpage.html'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
@@ -163,7 +166,6 @@ export default defineConfig({
|
|||||||
|| id.includes('/node_modules/@vue/')
|
|| id.includes('/node_modules/@vue/')
|
||||||
) return 'vendor-vue';
|
) return 'vendor-vue';
|
||||||
if (id.includes('dayjs')) return 'vendor-dayjs';
|
if (id.includes('dayjs')) return 'vendor-dayjs';
|
||||||
if (id.includes('qrious')) return 'vendor-qrious';
|
|
||||||
if (id.includes('axios')) return 'vendor-axios';
|
if (id.includes('axios')) return 'vendor-axios';
|
||||||
if (
|
if (
|
||||||
id.includes('vue3-persian-datetime-picker')
|
id.includes('vue3-persian-datetime-picker')
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>3x-ui · Xray</title>
|
<title>Xray Config</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="message"></div>
|
<div id="message"></div>
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ require (
|
|||||||
github.com/xlzd/gotp v0.1.0
|
github.com/xlzd/gotp v0.1.0
|
||||||
github.com/xtls/xray-core v1.260327.0
|
github.com/xtls/xray-core v1.260327.0
|
||||||
go.uber.org/atomic v1.11.0
|
go.uber.org/atomic v1.11.0
|
||||||
golang.org/x/crypto v0.50.0
|
golang.org/x/crypto v0.51.0
|
||||||
golang.org/x/sys v0.43.0
|
golang.org/x/sys v0.44.0
|
||||||
golang.org/x/text v0.36.0
|
golang.org/x/text v0.37.0
|
||||||
google.golang.org/grpc v1.81.0
|
google.golang.org/grpc v1.81.0
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
@@ -69,13 +70,13 @@ require (
|
|||||||
github.com/pires/go-proxyproto v0.12.0 // indirect
|
github.com/pires/go-proxyproto v0.12.0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||||
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
|
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/sagernet/sing v0.8.9 // indirect
|
github.com/sagernet/sing v0.8.10 // indirect
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
|
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
@@ -86,16 +87,16 @@ require (
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
golang.org/x/arch v0.26.0 // indirect
|
golang.org/x/arch v0.27.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||||
golang.org/x/mod v0.35.0 // indirect
|
golang.org/x/mod v0.36.0 // indirect
|
||||||
golang.org/x/net v0.53.0 // indirect
|
golang.org/x/net v0.54.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.44.0 // indirect
|
golang.org/x/tools v0.45.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||||
lukechampine.com/blake3 v1.4.1 // indirect
|
lukechampine.com/blake3 v1.4.1 // indirect
|
||||||
|
|||||||
@@ -148,16 +148,16 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
|
|||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
|
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
|
||||||
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/sagernet/sing v0.8.9 h1:iX8FyMrWNl/divVgTe7cLT9n36v6bfzfnCYlcM1cLaU=
|
github.com/sagernet/sing v0.8.10 h1:V5VZffy8rm4dtBVKIpKa8vibRR2SiJprtu/10DFUalU=
|
||||||
github.com/sagernet/sing v0.8.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
github.com/sagernet/sing v0.8.10/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
|
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
|
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
|
github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
|
||||||
@@ -175,10 +175,10 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
|||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
@@ -225,16 +225,16 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4=
|
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
|
||||||
golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -242,22 +242,22 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
|
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
|
||||||
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
@@ -265,6 +265,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
|||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
+56
-9
@@ -528,21 +528,24 @@ ssl_cert_issue() {
|
|||||||
# Sets global `SSL_HOST` to the chosen domain/IP for Access URL usage
|
# Sets global `SSL_HOST` to the chosen domain/IP for Access URL usage
|
||||||
prompt_and_setup_ssl() {
|
prompt_and_setup_ssl() {
|
||||||
local panel_port="$1"
|
local panel_port="$1"
|
||||||
local web_base_path="$2" # expected without leading slash
|
local web_base_path="$2"
|
||||||
local server_ip="$3"
|
local server_ip="$3"
|
||||||
|
|
||||||
local ssl_choice=""
|
local ssl_choice=""
|
||||||
|
SSL_SCHEME="https"
|
||||||
|
|
||||||
echo -e "${yellow}Choose SSL certificate setup method:${plain}"
|
echo -e "${yellow}Choose SSL certificate setup method:${plain}"
|
||||||
echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
|
echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
|
||||||
echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
|
echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
|
||||||
echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
|
echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
|
||||||
|
echo -e "${green}4.${plain} Skip SSL (advanced — behind reverse proxy / SSH tunnel only)"
|
||||||
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
|
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
|
||||||
|
echo -e "${blue}Note:${plain} Option 4 serves the panel over plain HTTP — only safe behind nginx/Caddy or an SSH tunnel."
|
||||||
read -rp "Choose an option (default 2 for IP): " ssl_choice
|
read -rp "Choose an option (default 2 for IP): " ssl_choice
|
||||||
ssl_choice="${ssl_choice// /}" # Trim whitespace
|
ssl_choice="${ssl_choice// /}" # Trim whitespace
|
||||||
|
|
||||||
# Default to 2 (IP cert) if input is empty or invalid (not 1 or 3)
|
# Default to 2 (IP cert) if input is empty or invalid (not 1, 3 or 4)
|
||||||
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then
|
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" && "$ssl_choice" != "4" ]]; then
|
||||||
ssl_choice="2"
|
ssl_choice="2"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -653,6 +656,41 @@ prompt_and_setup_ssl() {
|
|||||||
|
|
||||||
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
|
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
|
4)
|
||||||
|
echo ""
|
||||||
|
echo -e "${red}⚠ Panel will be installed WITHOUT SSL/TLS.${plain}"
|
||||||
|
echo -e "${yellow}Login credentials and cookies will travel as plain HTTP.${plain}"
|
||||||
|
echo -e "${yellow}Only safe when:${plain}"
|
||||||
|
echo -e "${yellow} • A reverse proxy (nginx, Caddy, Traefik) terminates TLS for you, or${plain}"
|
||||||
|
echo -e "${yellow} • You access the panel exclusively via SSH tunnel${plain}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
SSL_SCHEME="http"
|
||||||
|
SSL_HOST="${server_ip}"
|
||||||
|
|
||||||
|
local bind_local=""
|
||||||
|
read -rp "Bind the panel to 127.0.0.1 only? (recommended — forces SSH tunnel / reverse-proxy access) [y/N]: " bind_local
|
||||||
|
if [[ "$bind_local" == "y" || "$bind_local" == "Y" ]]; then
|
||||||
|
${xui_folder}/x-ui setting -listenIP "127.0.0.1" > /dev/null 2>&1
|
||||||
|
SSL_HOST="127.0.0.1"
|
||||||
|
echo -e "${green}✓ Panel bound to 127.0.0.1 only. It is now unreachable from the public internet.${plain}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${green}SSH Port Forwarding — open the panel from your local machine via:${plain}"
|
||||||
|
echo -e " Standard SSH command:"
|
||||||
|
echo -e " ${yellow}ssh -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
|
||||||
|
echo -e " If using an SSH key:"
|
||||||
|
echo -e " ${yellow}ssh -i <sshkeypath> -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
|
||||||
|
echo -e " Then open in your browser:"
|
||||||
|
echo -e " ${yellow}http://localhost:2222/${web_base_path}${plain}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${yellow}Alternative: point a reverse proxy (nginx/Caddy) at 127.0.0.1:${panel_port} and let it terminate TLS.${plain}"
|
||||||
|
else
|
||||||
|
echo -e "${yellow}Panel will listen on all interfaces over plain HTTP. Make sure something else is terminating TLS in front of it.${plain}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
|
||||||
|
echo -e "${green}✓ SSL setup skipped.${plain}"
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
|
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
|
||||||
SSL_HOST="${server_ip}"
|
SSL_HOST="${server_ip}"
|
||||||
@@ -716,14 +754,18 @@ config_after_install() {
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||||
echo -e "${green} SSL Certificate Setup (MANDATORY) ${plain}"
|
echo -e "${green} SSL Certificate Setup (RECOMMENDED) ${plain}"
|
||||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||||
echo -e "${yellow}For security, SSL certificate is required for all panels.${plain}"
|
echo -e "${yellow}SSL is strongly recommended. Skip only if a reverse proxy${plain}"
|
||||||
|
echo -e "${yellow}or SSH tunnel handles TLS for you.${plain}"
|
||||||
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
prompt_and_setup_ssl "${config_port}" "${config_webBasePath}" "${server_ip}"
|
prompt_and_setup_ssl "${config_port}" "${config_webBasePath}" "${server_ip}"
|
||||||
|
|
||||||
|
# Retrieve the API token for display
|
||||||
|
local config_apiToken=$(${xui_folder}/x-ui setting -getApiToken true | grep -Eo 'apiToken: .+' | awk '{print $2}')
|
||||||
|
|
||||||
# Display final credentials and access information
|
# Display final credentials and access information
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||||
@@ -733,10 +775,15 @@ config_after_install() {
|
|||||||
echo -e "${green}Password: ${config_password}${plain}"
|
echo -e "${green}Password: ${config_password}${plain}"
|
||||||
echo -e "${green}Port: ${config_port}${plain}"
|
echo -e "${green}Port: ${config_port}${plain}"
|
||||||
echo -e "${green}WebBasePath: ${config_webBasePath}${plain}"
|
echo -e "${green}WebBasePath: ${config_webBasePath}${plain}"
|
||||||
echo -e "${green}Access URL: https://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}"
|
echo -e "${green}Access URL: ${SSL_SCHEME}://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}"
|
||||||
|
echo -e "${green}API Token: ${config_apiToken}${plain}"
|
||||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||||
echo -e "${yellow}⚠ IMPORTANT: Save these credentials securely!${plain}"
|
echo -e "${yellow}⚠ IMPORTANT: Save these credentials securely!${plain}"
|
||||||
echo -e "${yellow}⚠ SSL Certificate: Enabled and configured${plain}"
|
if [[ "$SSL_SCHEME" == "https" ]]; then
|
||||||
|
echo -e "${yellow}⚠ SSL Certificate: Enabled and configured${plain}"
|
||||||
|
else
|
||||||
|
echo -e "${yellow}⚠ SSL Certificate: Skipped — panel is HTTP-only. Use a reverse proxy or SSH tunnel.${plain}"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
local config_webBasePath=$(gen_random_string 18)
|
local config_webBasePath=$(gen_random_string 18)
|
||||||
echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}"
|
echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}"
|
||||||
@@ -752,7 +799,7 @@ config_after_install() {
|
|||||||
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
||||||
echo ""
|
echo ""
|
||||||
prompt_and_setup_ssl "${existing_port}" "${config_webBasePath}" "${server_ip}"
|
prompt_and_setup_ssl "${existing_port}" "${config_webBasePath}" "${server_ip}"
|
||||||
echo -e "${green}Access URL: https://${SSL_HOST}:${existing_port}/${config_webBasePath}${plain}"
|
echo -e "${green}Access URL: ${SSL_SCHEME}://${SSL_HOST}:${existing_port}/${config_webBasePath}${plain}"
|
||||||
else
|
else
|
||||||
# If a cert already exists, just show the access URL
|
# If a cert already exists, just show the access URL
|
||||||
echo -e "${green}Access URL: https://${server_ip}:${existing_port}/${config_webBasePath}${plain}"
|
echo -e "${green}Access URL: https://${server_ip}:${existing_port}/${config_webBasePath}${plain}"
|
||||||
@@ -785,7 +832,7 @@ config_after_install() {
|
|||||||
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
||||||
echo ""
|
echo ""
|
||||||
prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}"
|
prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}"
|
||||||
echo -e "${green}Access URL: https://${SSL_HOST}:${existing_port}/${existing_webBasePath}${plain}"
|
echo -e "${green}Access URL: ${SSL_SCHEME}://${SSL_HOST}:${existing_port}/${existing_webBasePath}${plain}"
|
||||||
else
|
else
|
||||||
echo -e "${green}SSL certificate already configured. No action needed.${plain}"
|
echo -e "${green}SSL certificate already configured. No action needed.${plain}"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+24
-19
@@ -11,17 +11,25 @@ import (
|
|||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/config"
|
"github.com/mhsanaei/3x-ui/v3/config"
|
||||||
"github.com/op/go-logging"
|
"github.com/op/go-logging"
|
||||||
|
|
||||||
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxLogBufferSize = 10240 // Maximum log entries kept in memory
|
maxLogBufferSize = 10240 // Maximum log entries kept in memory
|
||||||
logFileName = "3xui.log" // Log file name
|
logFileName = "3xui.log" // Log file name
|
||||||
timeFormat = "2006/01/02 15:04:05" // Log timestamp format
|
timeFormat = "2006/01/02 15:04:05" // Log timestamp format
|
||||||
|
|
||||||
|
// On-disk rotation limits — single file capped, old segments pruned automatically.
|
||||||
|
maxLogFileMB = 10 // rotate active log when larger than this
|
||||||
|
maxLogBackups = 5 // rotated files retained (beyond current segment)
|
||||||
|
maxLogAgeDays = 7 // remove rotated backups older than this (0 disables time-based pruning)
|
||||||
|
compressRotated = true
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
logger *logging.Logger
|
logger *logging.Logger
|
||||||
logFile *os.File
|
fileRotate *lumberjack.Logger // nil when file backend disabled
|
||||||
|
|
||||||
// logBuffer maintains recent log entries in memory for web UI retrieval
|
// logBuffer maintains recent log entries in memory for web UI retrieval
|
||||||
logBuffer []struct {
|
logBuffer []struct {
|
||||||
@@ -81,8 +89,8 @@ func initDefaultBackend() logging.Backend {
|
|||||||
return logging.NewBackendFormatter(backend, newFormatter(includeTime))
|
return logging.NewBackendFormatter(backend, newFormatter(includeTime))
|
||||||
}
|
}
|
||||||
|
|
||||||
// initFileBackend creates the file logging backend.
|
// initFileBackend creates the file logging backend with size/age‑bounded rotation
|
||||||
// Creates log directory and truncates log file on startup for fresh logs.
|
// so log volume cannot grow without limit on disk.
|
||||||
func initFileBackend() logging.Backend {
|
func initFileBackend() logging.Backend {
|
||||||
logDir := config.GetLogFolder()
|
logDir := config.GetLogFolder()
|
||||||
if err := os.MkdirAll(logDir, 0o750); err != nil {
|
if err := os.MkdirAll(logDir, 0o750); err != nil {
|
||||||
@@ -91,19 +99,16 @@ func initFileBackend() logging.Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logPath := filepath.Join(logDir, logFileName)
|
logPath := filepath.Join(logDir, logFileName)
|
||||||
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o660)
|
fileRotate = &lumberjack.Logger{
|
||||||
if err != nil {
|
Filename: logPath,
|
||||||
fmt.Fprintf(os.Stderr, "failed to open log file %s: %v\n", logPath, err)
|
MaxSize: maxLogFileMB,
|
||||||
return nil
|
MaxBackups: maxLogBackups,
|
||||||
|
MaxAge: maxLogAgeDays,
|
||||||
|
LocalTime: true,
|
||||||
|
Compress: compressRotated,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close previous log file if exists
|
backend := logging.NewLogBackend(fileRotate, "", 0)
|
||||||
if logFile != nil {
|
|
||||||
_ = logFile.Close()
|
|
||||||
}
|
|
||||||
logFile = file
|
|
||||||
|
|
||||||
backend := logging.NewLogBackend(file, "", 0)
|
|
||||||
return logging.NewBackendFormatter(backend, newFormatter(true))
|
return logging.NewBackendFormatter(backend, newFormatter(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,12 +121,12 @@ func newFormatter(withTime bool) logging.Formatter {
|
|||||||
return logging.MustStringFormatter(format)
|
return logging.MustStringFormatter(format)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseLogger closes the log file and cleans up resources.
|
// CloseLogger closes the rotating log writer and cleans up resources.
|
||||||
// Should be called during application shutdown.
|
// Should be called during application shutdown.
|
||||||
func CloseLogger() {
|
func CloseLogger() {
|
||||||
if logFile != nil {
|
if fileRotate != nil {
|
||||||
_ = logFile.Close()
|
_ = fileRotate.Close()
|
||||||
logFile = nil
|
fileRotate = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ func runWebServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var subServer *sub.Server
|
var subServer *sub.Server
|
||||||
|
sub.SetDistFS(web.EmbeddedDist())
|
||||||
|
service.RegisterSubLinkProvider(sub.NewLinkProvider())
|
||||||
subServer = sub.NewServer()
|
subServer = sub.NewServer()
|
||||||
global.SetSubServer(subServer)
|
global.SetSubServer(subServer)
|
||||||
err = subServer.Start()
|
err = subServer.Start()
|
||||||
@@ -79,11 +81,7 @@ func runWebServer() {
|
|||||||
case syscall.SIGHUP:
|
case syscall.SIGHUP:
|
||||||
logger.Info("Received SIGHUP signal. Restarting servers...")
|
logger.Info("Received SIGHUP signal. Restarting servers...")
|
||||||
|
|
||||||
// --- FIX FOR TELEGRAM BOT CONFLICT (409): Stop bot before restart ---
|
err := server.StopPanelOnly()
|
||||||
service.StopBot()
|
|
||||||
// --
|
|
||||||
|
|
||||||
err := server.Stop()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debug("Error stopping web server:", err)
|
logger.Debug("Error stopping web server:", err)
|
||||||
}
|
}
|
||||||
@@ -94,13 +92,14 @@ func runWebServer() {
|
|||||||
|
|
||||||
server = web.NewServer()
|
server = web.NewServer()
|
||||||
global.SetWebServer(server)
|
global.SetWebServer(server)
|
||||||
err = server.Start()
|
err = server.StartPanelOnly()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error restarting web server: %v", err)
|
log.Fatalf("Error restarting web server: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Println("Web server restarted successfully.")
|
log.Println("Web server restarted successfully.")
|
||||||
|
|
||||||
|
sub.SetDistFS(web.EmbeddedDist())
|
||||||
subServer = sub.NewServer()
|
subServer = sub.NewServer()
|
||||||
global.SetSubServer(subServer)
|
global.SetSubServer(subServer)
|
||||||
err = subServer.Start()
|
err = subServer.Start()
|
||||||
@@ -392,6 +391,28 @@ func GetListenIP(getListen bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetApiToken(getApiToken bool) {
|
||||||
|
if !getApiToken {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiTokenService := service.ApiTokenService{}
|
||||||
|
tokens, err := apiTokenService.List()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("get apiToken failed, error info:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(tokens) > 0 {
|
||||||
|
fmt.Println("apiToken:", tokens[0].Token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, err := apiTokenService.Create("install")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("create apiToken failed, error info:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("apiToken:", created.Token)
|
||||||
|
}
|
||||||
|
|
||||||
// migrateDb performs database migration operations for the 3x-ui panel.
|
// migrateDb performs database migration operations for the 3x-ui panel.
|
||||||
func migrateDb() {
|
func migrateDb() {
|
||||||
inboundService := service.InboundService{}
|
inboundService := service.InboundService{}
|
||||||
@@ -434,6 +455,7 @@ func main() {
|
|||||||
var reset bool
|
var reset bool
|
||||||
var show bool
|
var show bool
|
||||||
var getCert bool
|
var getCert bool
|
||||||
|
var getApiToken bool
|
||||||
var resetTwoFactor bool
|
var resetTwoFactor bool
|
||||||
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
|
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
|
||||||
settingCmd.BoolVar(&show, "show", false, "Display current settings")
|
settingCmd.BoolVar(&show, "show", false, "Display current settings")
|
||||||
@@ -445,6 +467,7 @@ func main() {
|
|||||||
settingCmd.BoolVar(&resetTwoFactor, "resetTwoFactor", false, "Reset two-factor authentication settings")
|
settingCmd.BoolVar(&resetTwoFactor, "resetTwoFactor", false, "Reset two-factor authentication settings")
|
||||||
settingCmd.BoolVar(&getListen, "getListen", false, "Display current panel listenIP IP")
|
settingCmd.BoolVar(&getListen, "getListen", false, "Display current panel listenIP IP")
|
||||||
settingCmd.BoolVar(&getCert, "getCert", false, "Display current certificate settings")
|
settingCmd.BoolVar(&getCert, "getCert", false, "Display current certificate settings")
|
||||||
|
settingCmd.BoolVar(&getApiToken, "getApiToken", false, "Display current API token")
|
||||||
settingCmd.StringVar(&webCertFile, "webCert", "", "Set path to public key file for panel")
|
settingCmd.StringVar(&webCertFile, "webCert", "", "Set path to public key file for panel")
|
||||||
settingCmd.StringVar(&webKeyFile, "webCertKey", "", "Set path to private key file for panel")
|
settingCmd.StringVar(&webKeyFile, "webCertKey", "", "Set path to private key file for panel")
|
||||||
settingCmd.StringVar(&tgbottoken, "tgbottoken", "", "Set token for Telegram bot")
|
settingCmd.StringVar(&tgbottoken, "tgbottoken", "", "Set token for Telegram bot")
|
||||||
@@ -502,6 +525,9 @@ func main() {
|
|||||||
if getCert {
|
if getCert {
|
||||||
GetCertificate(getCert)
|
GetCertificate(getCert)
|
||||||
}
|
}
|
||||||
|
if getApiToken {
|
||||||
|
GetApiToken(getApiToken)
|
||||||
|
}
|
||||||
if (tgbottoken != "") || (tgbotchatid != "") || (tgbotRuntime != "") {
|
if (tgbottoken != "") || (tgbotchatid != "") || (tgbotRuntime != "") {
|
||||||
updateTgbotSetting(tgbottoken, tgbotchatid, tgbotRuntime)
|
updateTgbotSetting(tgbottoken, tgbotchatid, tgbotRuntime)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// distFS holds the Vite-built frontend filesystem, injected from main at
|
||||||
|
// startup. The `web` package owns the //go:embed directive (because dist/
|
||||||
|
// is at web/dist/), and hands the FS over via SetDistFS so the sub package
|
||||||
|
// doesn't import web — that would create an import cycle once any
|
||||||
|
// web/controller handler reuses sub's link-building service.
|
||||||
|
var distFS embed.FS
|
||||||
|
|
||||||
|
// SetDistFS installs the embedded frontend filesystem the sub server uses
|
||||||
|
// for its info page assets. Must be called before NewServer().Start().
|
||||||
|
func SetDistFS(fs embed.FS) {
|
||||||
|
distFS = fs
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LinkProvider struct {
|
||||||
|
settingService service.SettingService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLinkProvider() *LinkProvider {
|
||||||
|
return &LinkProvider{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LinkProvider) build(host string) *SubService {
|
||||||
|
showInfo, _ := p.settingService.GetSubShowInfo()
|
||||||
|
rModel, err := p.settingService.GetRemarkModel()
|
||||||
|
if err != nil {
|
||||||
|
rModel = "-ieo"
|
||||||
|
}
|
||||||
|
svc := NewSubService(showInfo, rModel)
|
||||||
|
svc.PrepareForRequest(host)
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) {
|
||||||
|
svc := p.build(host)
|
||||||
|
links, _, _, err := svc.GetSubs(subId, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(links))
|
||||||
|
for _, l := range links {
|
||||||
|
out = append(out, splitLinkLines(l)...)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
|
||||||
|
svc := p.build(host)
|
||||||
|
return splitLinkLines(svc.GetLink(inbound, email))
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLinkLines(raw string) []string {
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, "\n")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+5
-3
@@ -12,10 +12,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/util/common"
|
"github.com/mhsanaei/3x-ui/v3/util/common"
|
||||||
webpkg "github.com/mhsanaei/3x-ui/v3/web"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/locale"
|
"github.com/mhsanaei/3x-ui/v3/web/locale"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/middleware"
|
"github.com/mhsanaei/3x-ui/v3/web/middleware"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/network"
|
"github.com/mhsanaei/3x-ui/v3/web/network"
|
||||||
@@ -188,7 +188,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||||||
var assetsFS http.FileSystem
|
var assetsFS http.FileSystem
|
||||||
if _, err := os.Stat("web/dist/assets"); err == nil {
|
if _, err := os.Stat("web/dist/assets"); err == nil {
|
||||||
assetsFS = http.FS(os.DirFS("web/dist/assets"))
|
assetsFS = http.FS(os.DirFS("web/dist/assets"))
|
||||||
} else if subFS, err := fs.Sub(webpkg.EmbeddedDist(), "dist/assets"); err == nil {
|
} else if subFS, err := fs.Sub(distFS, "dist/assets"); err == nil {
|
||||||
assetsFS = http.FS(subFS)
|
assetsFS = http.FS(subFS)
|
||||||
} else {
|
} else {
|
||||||
logger.Error("sub: failed to mount embedded dist assets:", err)
|
logger.Error("sub: failed to mount embedded dist assets:", err)
|
||||||
@@ -313,7 +313,9 @@ func (s *Server) Stop() error {
|
|||||||
var err1 error
|
var err1 error
|
||||||
var err2 error
|
var err2 error
|
||||||
if s.httpServer != nil {
|
if s.httpServer != nil {
|
||||||
err1 = s.httpServer.Shutdown(s.ctx)
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
err1 = s.httpServer.Shutdown(shutdownCtx)
|
||||||
}
|
}
|
||||||
if s.listener != nil {
|
if s.listener != nil {
|
||||||
err2 = s.listener.Close()
|
err2 = s.listener.Close()
|
||||||
|
|||||||
+14
-9
@@ -6,10 +6,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
webpkg "github.com/mhsanaei/3x-ui/v3/web"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -127,7 +127,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||||||
basePath = "/"
|
basePath = "/"
|
||||||
}
|
}
|
||||||
basePathStr := basePath.(string)
|
basePathStr := basePath.(string)
|
||||||
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr)
|
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
|
||||||
a.serveSubPage(c, basePathStr, page)
|
a.serveSubPage(c, basePathStr, page)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -150,15 +150,20 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||||||
|
|
||||||
// serveSubPage renders web/dist/subpage.html for the current subscription
|
// serveSubPage renders web/dist/subpage.html for the current subscription
|
||||||
// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
|
// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
|
||||||
// we inject that here, along with window.__X_UI_BASE_PATH__ so the
|
// we inject that here, along with window.X_UI_BASE_PATH so the
|
||||||
// page's static asset references resolve correctly when the panel runs
|
// page's static asset references resolve correctly when the panel runs
|
||||||
// behind a URL prefix.
|
// behind a URL prefix.
|
||||||
func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
|
func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
|
||||||
dist := webpkg.EmbeddedDist()
|
var body []byte
|
||||||
body, err := dist.ReadFile("dist/subpage.html")
|
if diskBody, diskErr := os.ReadFile("web/dist/subpage.html"); diskErr == nil {
|
||||||
if err != nil {
|
body = diskBody
|
||||||
c.String(http.StatusInternalServerError, "missing embedded subpage")
|
} else {
|
||||||
return
|
readBody, err := distFS.ReadFile("dist/subpage.html")
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "missing embedded subpage")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body = readBody
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vite emits absolute asset URLs (`/assets/...`); when the panel is
|
// Vite emits absolute asset URLs (`/assets/...`); when the panel is
|
||||||
@@ -219,7 +224,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
|
|||||||
)
|
)
|
||||||
escapedBase := jsEscape.Replace(basePath)
|
escapedBase := jsEscape.Replace(basePath)
|
||||||
|
|
||||||
inject := []byte(`<script>window.__X_UI_BASE_PATH__="` + escapedBase + `";` +
|
inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase + `";` +
|
||||||
`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
|
`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
|
||||||
out := bytes.Replace(body, []byte("</head>"), inject, 1)
|
out := bytes.Replace(body, []byte("</head>"), inject, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -265,6 +265,14 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
|
|||||||
streamSettings["wsSettings"] = s.removeAcceptProxy(streamSettings["wsSettings"])
|
streamSettings["wsSettings"] = s.removeAcceptProxy(streamSettings["wsSettings"])
|
||||||
case "httpupgrade":
|
case "httpupgrade":
|
||||||
streamSettings["httpupgradeSettings"] = s.removeAcceptProxy(streamSettings["httpupgradeSettings"])
|
streamSettings["httpupgradeSettings"] = s.removeAcceptProxy(streamSettings["httpupgradeSettings"])
|
||||||
|
case "xhttp":
|
||||||
|
streamSettings["xhttpSettings"] = s.removeAcceptProxy(streamSettings["xhttpSettings"])
|
||||||
|
if xhttp, ok := streamSettings["xhttpSettings"].(map[string]any); ok {
|
||||||
|
delete(xhttp, "noSSEHeader")
|
||||||
|
delete(xhttp, "scMaxBufferedPosts")
|
||||||
|
delete(xhttp, "scStreamUpServerSecs")
|
||||||
|
delete(xhttp, "serverMaxHeaderBytes")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return streamSettings
|
return streamSettings
|
||||||
}
|
}
|
||||||
@@ -447,6 +455,9 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
|
|||||||
if udpIdleTimeout, ok := hyStream["udpIdleTimeout"].(float64); ok {
|
if udpIdleTimeout, ok := hyStream["udpIdleTimeout"].(float64); ok {
|
||||||
outHyStream["udpIdleTimeout"] = int(udpIdleTimeout)
|
outHyStream["udpIdleTimeout"] = int(udpIdleTimeout)
|
||||||
}
|
}
|
||||||
|
if masquerade, ok := hyStream["masquerade"].(map[string]any); ok {
|
||||||
|
outHyStream["masquerade"] = masquerade
|
||||||
|
}
|
||||||
newStream["hysteriaSettings"] = outHyStream
|
newStream["hysteriaSettings"] = outHyStream
|
||||||
|
|
||||||
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
|
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
|
||||||
|
|||||||
+61
-42
@@ -28,6 +28,7 @@ type SubService struct {
|
|||||||
showInfo bool
|
showInfo bool
|
||||||
remarkModel string
|
remarkModel string
|
||||||
datepicker string
|
datepicker string
|
||||||
|
emailInRemark bool
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
// nodesByID is populated per request from the Node table so
|
// nodesByID is populated per request from the Node table so
|
||||||
@@ -76,6 +77,12 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
s.datepicker = "gregorian"
|
s.datepicker = "gregorian"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.emailInRemark, err = s.settingService.GetSubEmailInRemark()
|
||||||
|
if err != nil {
|
||||||
|
s.emailInRemark = true
|
||||||
|
}
|
||||||
|
|
||||||
seenEmails := make(map[string]struct{})
|
seenEmails := make(map[string]struct{})
|
||||||
for _, inbound := range inbounds {
|
for _, inbound := range inbounds {
|
||||||
clients, err := s.inboundService.GetClients(inbound)
|
clients, err := s.inboundService.GetClients(inbound)
|
||||||
@@ -98,7 +105,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
|
|||||||
if client.Enable {
|
if client.Enable {
|
||||||
hasEnabledClient = true
|
hasEnabledClient = true
|
||||||
}
|
}
|
||||||
result = append(result, s.getLink(inbound, client.Email))
|
result = append(result, s.GetLink(inbound, client.Email))
|
||||||
var ct xray.ClientTraffic
|
var ct xray.ClientTraffic
|
||||||
ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
|
ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
|
||||||
if ct.LastOnline > lastOnline {
|
if ct.LastOnline > lastOnline {
|
||||||
@@ -198,7 +205,11 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
|
|||||||
return inbound.Listen, inbound.Port, string(modifiedStream), nil
|
return inbound.Listen, inbound.Port, string(modifiedStream), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) getLink(inbound *model.Inbound, email string) string {
|
// GetLink dispatches to the protocol-specific generator for one (inbound, client)
|
||||||
|
// pair. Returns "" when the inbound's protocol doesn't produce a subscription URL
|
||||||
|
// (socks, http, mixed, wireguard, dokodemo, tunnel). The returned string may
|
||||||
|
// contain multiple `\n`-separated URLs when the inbound has externalProxy set.
|
||||||
|
func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
|
||||||
switch inbound.Protocol {
|
switch inbound.Protocol {
|
||||||
case "vmess":
|
case "vmess":
|
||||||
return s.genVmessLink(inbound, email)
|
return s.genVmessLink(inbound, email)
|
||||||
@@ -882,7 +893,7 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
|
|||||||
'e': "",
|
'e': "",
|
||||||
'o': "",
|
'o': "",
|
||||||
}
|
}
|
||||||
if len(email) > 0 {
|
if len(email) > 0 && s.emailInRemark {
|
||||||
orders['e'] = email
|
orders['e'] = email
|
||||||
}
|
}
|
||||||
if len(inbound.Remark) > 0 {
|
if len(inbound.Remark) > 0 {
|
||||||
@@ -1014,6 +1025,10 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if mode, ok := xhttp["mode"].(string); ok && len(mode) > 0 {
|
||||||
|
extra["mode"] = mode
|
||||||
|
}
|
||||||
|
|
||||||
stringFields := []string{
|
stringFields := []string{
|
||||||
"sessionPlacement", "sessionKey",
|
"sessionPlacement", "sessionKey",
|
||||||
"seqPlacement", "seqKey",
|
"seqPlacement", "seqKey",
|
||||||
@@ -1413,25 +1428,27 @@ func searchHost(headers any) string {
|
|||||||
// PageData is a view model for subpage.html
|
// PageData is a view model for subpage.html
|
||||||
// PageData contains data for rendering the subscription information page.
|
// PageData contains data for rendering the subscription information page.
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
Host string
|
Host string
|
||||||
BasePath string
|
BasePath string
|
||||||
SId string
|
SId string
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Download string
|
Download string
|
||||||
Upload string
|
Upload string
|
||||||
Total string
|
Total string
|
||||||
Used string
|
Used string
|
||||||
Remained string
|
Remained string
|
||||||
Expire int64
|
Expire int64
|
||||||
LastOnline int64
|
LastOnline int64
|
||||||
Datepicker string
|
Datepicker string
|
||||||
DownloadByte int64
|
DownloadByte int64
|
||||||
UploadByte int64
|
UploadByte int64
|
||||||
TotalByte int64
|
TotalByte int64
|
||||||
SubUrl string
|
SubUrl string
|
||||||
SubJsonUrl string
|
SubJsonUrl string
|
||||||
SubClashUrl string
|
SubClashUrl string
|
||||||
Result []string
|
SubTitle string
|
||||||
|
SubSupportUrl string
|
||||||
|
Result []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveRequest extracts scheme and host info from request/headers consistently.
|
// ResolveRequest extracts scheme and host info from request/headers consistently.
|
||||||
@@ -1545,7 +1562,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
|
|||||||
|
|
||||||
// BuildPageData parses header and prepares the template view model.
|
// BuildPageData parses header and prepares the template view model.
|
||||||
// BuildPageData constructs page data for rendering the subscription information page.
|
// BuildPageData constructs page data for rendering the subscription information page.
|
||||||
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string) PageData {
|
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string, subTitle string, subSupportUrl string) PageData {
|
||||||
download := common.FormatTraffic(traffic.Down)
|
download := common.FormatTraffic(traffic.Down)
|
||||||
upload := common.FormatTraffic(traffic.Up)
|
upload := common.FormatTraffic(traffic.Up)
|
||||||
total := "∞"
|
total := "∞"
|
||||||
@@ -1563,25 +1580,27 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
|
|||||||
}
|
}
|
||||||
|
|
||||||
return PageData{
|
return PageData{
|
||||||
Host: hostHeader,
|
Host: hostHeader,
|
||||||
BasePath: basePath,
|
BasePath: basePath,
|
||||||
SId: subId,
|
SId: subId,
|
||||||
Enabled: traffic.Enable,
|
Enabled: traffic.Enable,
|
||||||
Download: download,
|
Download: download,
|
||||||
Upload: upload,
|
Upload: upload,
|
||||||
Total: total,
|
Total: total,
|
||||||
Used: used,
|
Used: used,
|
||||||
Remained: remained,
|
Remained: remained,
|
||||||
Expire: traffic.ExpiryTime / 1000,
|
Expire: traffic.ExpiryTime / 1000,
|
||||||
LastOnline: lastOnline,
|
LastOnline: lastOnline,
|
||||||
Datepicker: datepicker,
|
Datepicker: datepicker,
|
||||||
DownloadByte: traffic.Down,
|
DownloadByte: traffic.Down,
|
||||||
UploadByte: traffic.Up,
|
UploadByte: traffic.Up,
|
||||||
TotalByte: traffic.Total,
|
TotalByte: traffic.Total,
|
||||||
SubUrl: subURL,
|
SubUrl: subURL,
|
||||||
SubJsonUrl: subJsonURL,
|
SubJsonUrl: subJsonURL,
|
||||||
SubClashUrl: subClashURL,
|
SubClashUrl: subClashURL,
|
||||||
Result: subs,
|
SubTitle: subTitle,
|
||||||
|
SubSupportUrl: subSupportUrl,
|
||||||
|
Result: subs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package netsafe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsBlockedIP(ip net.IP) bool {
|
||||||
|
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||||
|
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
|
||||||
|
}
|
||||||
|
|
||||||
|
type allowPrivateCtxKey struct{}
|
||||||
|
|
||||||
|
func ContextWithAllowPrivate(ctx context.Context, allow bool) context.Context {
|
||||||
|
return context.WithValue(ctx, allowPrivateCtxKey{}, allow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AllowPrivateFromContext(ctx context.Context) bool {
|
||||||
|
v, _ := ctx.Value(allowPrivateCtxKey{}).(bool)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultDialer = &net.Dialer{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
host, port, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
allowPrivate := AllowPrivateFromContext(ctx)
|
||||||
|
var ips []net.IPAddr
|
||||||
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
|
ips = []net.IPAddr{{IP: ip}}
|
||||||
|
} else {
|
||||||
|
ips, err = net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for _, ipAddr := range ips {
|
||||||
|
if !allowPrivate && IsBlockedIP(ipAddr.IP) {
|
||||||
|
lastErr = fmt.Errorf("blocked private/internal address %s", ipAddr.IP)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
conn, derr := defaultDialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))
|
||||||
|
if derr == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
lastErr = derr
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("no usable address for %s", host)
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostnamePattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`)
|
||||||
|
|
||||||
|
func NormalizeHost(addr string) (string, error) {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if addr == "" {
|
||||||
|
return "", fmt.Errorf("address is required")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(addr, "[") && strings.HasSuffix(addr, "]") {
|
||||||
|
addr = addr[1 : len(addr)-1]
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(addr); ip != nil {
|
||||||
|
return ip.String(), nil
|
||||||
|
}
|
||||||
|
if len(addr) > 253 || !hostnamePattern.MatchString(addr) {
|
||||||
|
return "", fmt.Errorf("invalid host %q", addr)
|
||||||
|
}
|
||||||
|
return addr, nil
|
||||||
|
}
|
||||||
+8
-17
@@ -19,6 +19,7 @@ type APIController struct {
|
|||||||
nodeController *NodeController
|
nodeController *NodeController
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
userService service.UserService
|
userService service.UserService
|
||||||
|
apiTokenService service.ApiTokenService
|
||||||
Tgbot service.Tgbot
|
Tgbot service.Tgbot
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,25 +30,11 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkAPIAuth is a middleware that returns 404 for unauthenticated API requests
|
|
||||||
// to hide the existence of API endpoints from unauthorized users.
|
|
||||||
//
|
|
||||||
// Two auth paths are accepted:
|
|
||||||
// 1. Authorization: Bearer <apiToken> — used by remote central panels
|
|
||||||
// polling this instance as a node. Matches via constant-time compare.
|
|
||||||
// Sets c.Set("api_authed", true) so CSRFMiddleware can short-circuit.
|
|
||||||
// 2. Existing session cookie — used by browsers logged into the panel UI.
|
|
||||||
//
|
|
||||||
// Anything else falls through to a 404 so the API endpoints remain hidden.
|
|
||||||
func (a *APIController) checkAPIAuth(c *gin.Context) {
|
func (a *APIController) checkAPIAuth(c *gin.Context) {
|
||||||
auth := c.GetHeader("Authorization")
|
auth := c.GetHeader("Authorization")
|
||||||
if strings.HasPrefix(auth, "Bearer ") {
|
if strings.HasPrefix(auth, "Bearer ") {
|
||||||
tok := strings.TrimPrefix(auth, "Bearer ")
|
tok := strings.TrimPrefix(auth, "Bearer ")
|
||||||
if a.settingService.MatchApiToken(tok) {
|
if a.apiTokenService.Match(tok) {
|
||||||
// Handlers like InboundController.addInbound assume a logged-in
|
|
||||||
// user (inbound.UserId = user.Id). Bearer callers have no
|
|
||||||
// session, so attach the first user as a fallback. Single-user
|
|
||||||
// panels are the norm here.
|
|
||||||
if u, err := a.userService.GetFirstUser(); err == nil {
|
if u, err := a.userService.GetFirstUser(); err == nil {
|
||||||
session.SetAPIAuthUser(c, u)
|
session.SetAPIAuthUser(c, u)
|
||||||
}
|
}
|
||||||
@@ -57,7 +44,11 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !session.IsLogin(c) {
|
if !session.IsLogin(c) {
|
||||||
c.AbortWithStatus(http.StatusNotFound)
|
if c.GetHeader("X-Requested-With") == "XMLHttpRequest" {
|
||||||
|
c.AbortWithStatus(http.StatusUnauthorized)
|
||||||
|
} else {
|
||||||
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
@@ -85,7 +76,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
|
|||||||
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
|
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
|
||||||
|
|
||||||
// Extra routes
|
// Extra routes
|
||||||
api.GET("/backuptotgbot", a.BackuptoTgbot)
|
api.POST("/backuptotgbot", a.BackuptoTgbot)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackuptoTgbot sends a backup of the panel data to Telegram bot admins.
|
// BackuptoTgbot sends a backup of the panel data to Telegram bot admins.
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type routeDef struct {
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
|
||||||
|
var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
|
||||||
|
|
||||||
|
// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.js.
|
||||||
|
var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
|
||||||
|
|
||||||
|
// buildDocSet parses frontend/src/pages/api-docs/endpoints.js and returns the
|
||||||
|
// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
|
||||||
|
// placeholders (paths starting with /{...}) are skipped because they aren't
|
||||||
|
// registered on the main Gin engine.
|
||||||
|
func buildDocSet(t *testing.T) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
controllerDir, err := filepath.Abs(".")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get current dir: %v", err)
|
||||||
|
}
|
||||||
|
endpointsPath := filepath.Join(controllerDir, "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.js")
|
||||||
|
data, err := os.ReadFile(endpointsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read endpoints.js at %s: %v", endpointsPath, err)
|
||||||
|
}
|
||||||
|
docSet := make(map[string]bool)
|
||||||
|
for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) {
|
||||||
|
method, path := m[1], m[2]
|
||||||
|
if method == "WS" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "/{") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
docSet[method+" "+path] = true
|
||||||
|
}
|
||||||
|
if len(docSet) == 0 {
|
||||||
|
t.Fatalf("no documented routes parsed from %s — regex or file format may have changed", endpointsPath)
|
||||||
|
}
|
||||||
|
return docSet
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIRoutesDocumented(t *testing.T) {
|
||||||
|
docSet := buildDocSet(t)
|
||||||
|
|
||||||
|
controllerDir, err := filepath.Abs(".")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get current dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var allRoutes []routeDef
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(controllerDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read controller dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(controllerDir, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read %s: %v", entry.Name(), err)
|
||||||
|
}
|
||||||
|
src := string(data)
|
||||||
|
|
||||||
|
// Determine the base path for this file based on its initRouter patterns
|
||||||
|
basePath := ""
|
||||||
|
switch entry.Name() {
|
||||||
|
case "index.go":
|
||||||
|
basePath = ""
|
||||||
|
case "xui.go":
|
||||||
|
basePath = "/panel"
|
||||||
|
case "api.go":
|
||||||
|
basePath = "/panel/api"
|
||||||
|
case "inbound.go":
|
||||||
|
basePath = "/panel/api/inbounds"
|
||||||
|
case "server.go":
|
||||||
|
basePath = "/panel/api/server"
|
||||||
|
case "node.go":
|
||||||
|
basePath = "/panel/api/nodes"
|
||||||
|
case "setting.go":
|
||||||
|
basePath = "/panel/setting"
|
||||||
|
case "xray_setting.go":
|
||||||
|
basePath = "/panel/xray"
|
||||||
|
case "custom_geo.go":
|
||||||
|
basePath = "/panel/api/custom-geo"
|
||||||
|
case "websocket.go":
|
||||||
|
basePath = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all route registrations
|
||||||
|
matches := routePattern.FindAllStringSubmatch(src, -1)
|
||||||
|
for _, m := range matches {
|
||||||
|
method := m[2]
|
||||||
|
path := strings.TrimSpace(m[3])
|
||||||
|
if basePath == "" {
|
||||||
|
allRoutes = append(allRoutes, routeDef{Method: method, Path: path})
|
||||||
|
} else {
|
||||||
|
fullPath := basePath + path
|
||||||
|
allRoutes = append(allRoutes, routeDef{Method: method, Path: fullPath})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The WebSocket route /ws is registered in web/web.go (not a controller file)
|
||||||
|
allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
|
||||||
|
|
||||||
|
missingFromDocs := 0
|
||||||
|
foundInDoc := 0
|
||||||
|
sourceSet := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, r := range allRoutes {
|
||||||
|
key := r.Method + " " + r.Path
|
||||||
|
// Skip SPA page routes (these are UI pages, not API endpoints)
|
||||||
|
spaPages := map[string]bool{
|
||||||
|
"/": true, "/panel/": true, "/panel/inbounds": true,
|
||||||
|
"/panel/nodes": true, "/panel/settings": true,
|
||||||
|
"/panel/xray": true, "/panel/api-docs": true,
|
||||||
|
}
|
||||||
|
if spaPages[r.Path] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip /panel/csrf-token (documented under auth as /csrf-token)
|
||||||
|
if r.Path == "/panel/csrf-token" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip Chrome DevTools route
|
||||||
|
if strings.Contains(r.Path, ".well-known") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSet[key] = true
|
||||||
|
if docSet[key] {
|
||||||
|
foundInDoc++
|
||||||
|
} else {
|
||||||
|
missingFromDocs++
|
||||||
|
t.Errorf("Route not documented in endpoints.js: %s %s", r.Method, r.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d",
|
||||||
|
len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
|
||||||
|
|
||||||
|
if missingFromDocs > 0 {
|
||||||
|
t.Errorf("Found %d undocumented route(s). Update endpoints.js to match.", missingFromDocs)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-47
@@ -15,41 +15,14 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/web/session"
|
"github.com/mhsanaei/3x-ui/v3/web/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// distFS is filled in once at startup by the web package via SetDistFS.
|
|
||||||
// It holds the Vite-built frontend (the `dist/<page>.html` files) so
|
|
||||||
// the panel's HTML routes can serve them in production.
|
|
||||||
//
|
|
||||||
// We can't `go:embed` the dist directory directly from this package
|
|
||||||
// because embed.FS only accepts paths relative to the source file —
|
|
||||||
// dist/ lives one directory up. The web package owns the embed and
|
|
||||||
// hands the FS to us through this setter.
|
|
||||||
var distFS embed.FS
|
var distFS embed.FS
|
||||||
|
|
||||||
// SetDistFS is called once during server bootstrap by the web package
|
|
||||||
// to hand off the embedded `dist/` filesystem.
|
|
||||||
func SetDistFS(fs embed.FS) {
|
func SetDistFS(fs embed.FS) {
|
||||||
distFS = fs
|
distFS = fs
|
||||||
}
|
}
|
||||||
|
|
||||||
// distPageBuildTime is captured at startup so every served HTML page
|
|
||||||
// reports a stable Last-Modified header and the browser's conditional
|
|
||||||
// GETs can hit the 304 path on repeat loads.
|
|
||||||
var distPageBuildTime = time.Now()
|
var distPageBuildTime = time.Now()
|
||||||
|
|
||||||
// serveDistPage reads `dist/<name>` from the embedded FS and writes it
|
|
||||||
// to the response. Two transforms run before send:
|
|
||||||
//
|
|
||||||
// 1. `<script>window.__X_UI_BASE_PATH__ = "..."</script>` is injected
|
|
||||||
// just before </head> so the AppSidebar's link generator sees the
|
|
||||||
// right prefix.
|
|
||||||
// 2. Absolute Vite-emitted asset URLs (`/assets/...`) are rewritten
|
|
||||||
// to include the panel's basePath, so installs running under a
|
|
||||||
// custom URL prefix (e.g. `/myprefix/`) load the bundle from
|
|
||||||
// `/myprefix/assets/...` where the static handler actually lives.
|
|
||||||
//
|
|
||||||
// The HTML responses are served with no-cache so a panel update
|
|
||||||
// reaches users on the next reload; the long-hashed JS/CSS files
|
|
||||||
// under /assets/ stay cacheable indefinitely.
|
|
||||||
func serveDistPage(c *gin.Context, name string) {
|
func serveDistPage(c *gin.Context, name string) {
|
||||||
body, err := distFS.ReadFile("dist/" + name)
|
body, err := distFS.ReadFile("dist/" + name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,21 +35,11 @@ func serveDistPage(c *gin.Context, name string) {
|
|||||||
basePath = "/"
|
basePath = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rewrite asset URLs only when basePath isn't the root — for the
|
|
||||||
// default `/` install, Vite's `/assets/...` already resolves
|
|
||||||
// correctly and we save the byte churn.
|
|
||||||
if basePath != "/" {
|
if basePath != "/" {
|
||||||
// Vite emits these three attribute shapes for every entry's
|
|
||||||
// JS / CSS / modulepreload reference. Anchoring the search to
|
|
||||||
// the leading attribute name avoids matching unrelated /assets
|
|
||||||
// substrings inside any inlined script.
|
|
||||||
body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
|
body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
|
||||||
body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
|
body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape just enough that a hostile basePath setting can't break
|
|
||||||
// out of the JS string literal. The setting is admin-controlled
|
|
||||||
// but defense-in-depth costs nothing here.
|
|
||||||
jsEscape := strings.NewReplacer(
|
jsEscape := strings.NewReplacer(
|
||||||
`\`, `\\`,
|
`\`, `\\`,
|
||||||
`"`, `\"`,
|
`"`, `\"`,
|
||||||
@@ -87,14 +50,6 @@ func serveDistPage(c *gin.Context, name string) {
|
|||||||
"&", `&`,
|
"&", `&`,
|
||||||
)
|
)
|
||||||
escapedBase := jsEscape.Replace(basePath)
|
escapedBase := jsEscape.Replace(basePath)
|
||||||
escapedVer := jsEscape.Replace(config.GetVersion())
|
|
||||||
|
|
||||||
// Embed a CSRF token in the served HTML the same way the legacy
|
|
||||||
// templates did via `<meta name="csrf-token">`. Without this the
|
|
||||||
// SPA login page has no way to acquire a token (the existing
|
|
||||||
// /panel/csrf-token endpoint sits behind checkLogin), and POST
|
|
||||||
// /login is rejected by CSRFMiddleware. EnsureCSRFToken creates
|
|
||||||
// a session token on first call even for anonymous visitors.
|
|
||||||
csrfToken, err := session.EnsureCSRFToken(c)
|
csrfToken, err := session.EnsureCSRFToken(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("Unable to mint CSRF token for", name+":", err)
|
logger.Warning("Unable to mint CSRF token for", name+":", err)
|
||||||
@@ -102,8 +57,17 @@ func serveDistPage(c *gin.Context, name string) {
|
|||||||
}
|
}
|
||||||
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
|
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
|
||||||
|
|
||||||
inject := []byte(`<script>window.__X_UI_BASE_PATH__="` + escapedBase +
|
nonceAttr := ""
|
||||||
`";window.__X_UI_CUR_VER__="` + escapedVer + `";</script>`)
|
if nonce := c.GetString("csp_nonce"); nonce != "" {
|
||||||
|
nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
|
||||||
|
}
|
||||||
|
script := `<script` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
|
||||||
|
if name != "login.html" {
|
||||||
|
escapedVer := jsEscape.Replace(config.GetVersion())
|
||||||
|
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
|
||||||
|
}
|
||||||
|
script += `;</script>`
|
||||||
|
inject := []byte(script)
|
||||||
inject = append(inject, csrfMeta...)
|
inject = append(inject, csrfMeta...)
|
||||||
inject = append(inject, []byte(`</head>`)...)
|
inject = append(inject, []byte(`</head>`)...)
|
||||||
out := bytes.Replace(body, []byte("</head>"), inject, 1)
|
out := bytes.Replace(body, []byte("</head>"), inject, 1)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
@@ -62,6 +64,8 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.GET("/get/:id", a.getInbound)
|
g.GET("/get/:id", a.getInbound)
|
||||||
g.GET("/getClientTraffics/:email", a.getClientTraffics)
|
g.GET("/getClientTraffics/:email", a.getClientTraffics)
|
||||||
g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
|
g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
|
||||||
|
g.GET("/getSubLinks/:subId", a.getSubLinks)
|
||||||
|
g.GET("/getClientLinks/:id/:email", a.getClientLinks)
|
||||||
|
|
||||||
g.POST("/add", a.addInbound)
|
g.POST("/add", a.addInbound)
|
||||||
g.POST("/del/:id", a.delInbound)
|
g.POST("/del/:id", a.delInbound)
|
||||||
@@ -73,6 +77,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/:id/copyClients", a.copyInboundClients)
|
g.POST("/:id/copyClients", a.copyInboundClients)
|
||||||
g.POST("/:id/delClient/:clientId", a.delInboundClient)
|
g.POST("/:id/delClient/:clientId", a.delInboundClient)
|
||||||
g.POST("/updateClient/:clientId", a.updateInboundClient)
|
g.POST("/updateClient/:clientId", a.updateInboundClient)
|
||||||
|
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
|
||||||
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
|
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
|
||||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||||
g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
|
g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
|
||||||
@@ -437,6 +442,24 @@ func (a *InboundController) resetClientTraffic(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetInboundTraffic resets traffic counters for a specific inbound.
|
||||||
|
func (a *InboundController) resetInboundTraffic(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.inboundService.ResetInboundTraffic(id)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundTrafficSuccess"), nil)
|
||||||
|
}
|
||||||
|
|
||||||
// resetAllTraffics resets all traffic counters across all inbounds.
|
// resetAllTraffics resets all traffic counters across all inbounds.
|
||||||
func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
||||||
err := a.inboundService.ResetAllTraffics()
|
err := a.inboundService.ResetAllTraffics()
|
||||||
@@ -571,3 +594,57 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
|
|||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveHost mirrors what sub.SubService.ResolveRequest does for the host
|
||||||
|
// field: prefers X-Forwarded-Host (first entry of any list, port stripped),
|
||||||
|
// then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
|
||||||
|
// controller layer means the service interface stays HTTP-agnostic — service
|
||||||
|
// methods receive a plain host string instead of a *gin.Context.
|
||||||
|
func resolveHost(c *gin.Context) string {
|
||||||
|
if isTrustedForwardedRequest(c) {
|
||||||
|
if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" {
|
||||||
|
if i := strings.Index(h, ","); i >= 0 {
|
||||||
|
h = strings.TrimSpace(h[:i])
|
||||||
|
}
|
||||||
|
if hp, _, err := net.SplitHostPort(h); err == nil {
|
||||||
|
return hp
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
if h := c.GetHeader("X-Real-IP"); h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h, _, err := net.SplitHostPort(c.Request.Host); err == nil {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
return c.Request.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSubLinks returns every protocol URL produced for the given subscription
|
||||||
|
// ID — the JSON-array equivalent of /sub/<subId> (no base64 wrap).
|
||||||
|
func (a *InboundController) getSubLinks(c *gin.Context) {
|
||||||
|
links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, links, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getClientLinks returns the URL(s) for one client on one inbound — the same
|
||||||
|
// string the Copy URL button copies in the panel UI. Empty array when the
|
||||||
|
// protocol has no URL form, or when the email isn't found on the inbound.
|
||||||
|
func (a *InboundController) getClientLinks(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, links, nil)
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user