mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-27 02:12:22 +03:00
Compare commits
61 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 |
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
@@ -9,3 +9,11 @@ updates:
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
91
.github/workflows/ci.yml
vendored
Normal file
91
.github/workflows/ci.yml
vendored
Normal file
@@ -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
|
||||
25
.github/workflows/codeql.yml
vendored
25
.github/workflows/codeql.yml
vendored
@@ -2,9 +2,31 @@ name: "CodeQL Advanced"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags-ignore:
|
||||
- "v*"
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "**.vue"
|
||||
- "frontend/package-lock.json"
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "**.vue"
|
||||
- "frontend/package-lock.json"
|
||||
schedule:
|
||||
- cron: "18 2 * * 2"
|
||||
|
||||
@@ -35,9 +57,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# The Go binary embeds web/dist/ via //go:embed all:dist (web/web.go).
|
||||
# web/dist/ is .gitignored, so CodeQL's autobuild for Go will fail with
|
||||
# "pattern all:dist: no matching files found" unless vite emits it first.
|
||||
- name: Setup Node.js
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@@ -19,6 +19,17 @@ on:
|
||||
- "x-ui.service.arch"
|
||||
- "x-ui.service.rhel"
|
||||
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:
|
||||
build:
|
||||
|
||||
@@ -22,7 +22,7 @@ EOF
|
||||
|
||||
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
|
||||
[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+
|
||||
ignoreregex =
|
||||
EOF
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.0.1
|
||||
3.0.2
|
||||
116
database/db.go
116
database/db.go
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/config"
|
||||
@@ -40,9 +41,14 @@ func initModels() error {
|
||||
&model.HistoryOfSeeders{},
|
||||
&model.CustomGeoResource{},
|
||||
&model.Node{},
|
||||
&model.ApiToken{},
|
||||
}
|
||||
for _, model := range models {
|
||||
if err := db.AutoMigrate(model); err != nil {
|
||||
for _, mdl := range models {
|
||||
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)
|
||||
return err
|
||||
}
|
||||
@@ -50,6 +56,27 @@ func initModels() error {
|
||||
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.
|
||||
func initUser() error {
|
||||
empty, err := isTableEmpty("users")
|
||||
@@ -86,43 +113,80 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
return db.Create(hashSeeder).Error
|
||||
} else {
|
||||
var seedersHistory []string
|
||||
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
|
||||
log.Printf("Error fetching seeder history: %v", err)
|
||||
if err := db.Create(hashSeeder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return seedApiTokens()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
for _, user := range users {
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
||||
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
|
||||
}
|
||||
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{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
return db.Create(hashSeeder).Error
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
if err := db.Create(hashSeeder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "ApiTokensTable") {
|
||||
if err := seedApiTokens(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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.
|
||||
func isTableEmpty(tableName string) (bool, error) {
|
||||
var count int64
|
||||
|
||||
@@ -21,12 +21,8 @@ const (
|
||||
Shadowsocks Protocol = "shadowsocks"
|
||||
Mixed Protocol = "mixed"
|
||||
WireGuard Protocol = "wireguard"
|
||||
// UI stores Hysteria v1 and v2 both as "hysteria" and uses
|
||||
// settings.version to discriminate. Imports from outside the panel
|
||||
// can carry the literal "hysteria2" string, so IsHysteria below
|
||||
// accepts both.
|
||||
Hysteria Protocol = "hysteria"
|
||||
Hysteria2 Protocol = "hysteria2"
|
||||
Hysteria Protocol = "hysteria"
|
||||
Hysteria2 Protocol = "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.
|
||||
type User struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
LoginEpoch int64 `json:"-" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
||||
@@ -66,12 +63,7 @@ type Inbound struct {
|
||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||
Sniffing string `json:"sniffing" form:"sniffing"`
|
||||
|
||||
// NodeID points at the remote panel (Node) where this inbound's xray
|
||||
// actually runs. NULL means the inbound runs on the local xray (the
|
||||
// pre-multi-node behaviour). Existing rows migrate to NULL with no
|
||||
// backfill.
|
||||
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||
}
|
||||
|
||||
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
||||
@@ -96,6 +88,14 @@ type HistoryOfSeeders struct {
|
||||
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.
|
||||
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
||||
listen := i.Listen
|
||||
@@ -128,15 +128,16 @@ type Setting struct {
|
||||
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
||||
// status fields below.
|
||||
type Node struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Scheme string `json:"scheme" form:"scheme"`
|
||||
Address string `json:"address" form:"address"`
|
||||
Port int `json:"port" form:"port"`
|
||||
BasePath string `json:"basePath" form:"basePath"`
|
||||
ApiToken string `json:"apiToken" form:"apiToken"`
|
||||
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Scheme string `json:"scheme" form:"scheme"`
|
||||
Address string `json:"address" form:"address"`
|
||||
Port int `json:"port" form:"port"`
|
||||
BasePath string `json:"basePath" form:"basePath"`
|
||||
ApiToken string `json:"apiToken" form:"apiToken"`
|
||||
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
||||
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
||||
|
||||
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
||||
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
||||
|
||||
@@ -12,5 +12,6 @@ services:
|
||||
XRAY_VMESS_AEAD_FORCED: "false"
|
||||
XUI_ENABLE_FAIL2BAN: "true"
|
||||
tty: true
|
||||
network_mode: host
|
||||
ports:
|
||||
- "2053:2053"
|
||||
restart: unless-stopped
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · API Docs</title>
|
||||
<title>API Docs</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
|
||||
@@ -19,9 +19,6 @@ export default [
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
// Legacy script tags inject a couple of helpers on window before
|
||||
// the SPA boots; declared here so no-undef stops flagging them.
|
||||
getRandomRealityTarget: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · Inbounds</title>
|
||||
<title>Inbounds</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui</title>
|
||||
<title>Overview</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>3x-ui — Sign in</title>
|
||||
<title>Sign in</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · Nodes</title>
|
||||
<title>Nodes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
|
||||
553
frontend/package-lock.json
generated
553
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"name": "3x-ui-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"type": "module",
|
||||
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@@ -12,8 +16,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"axios": "^1.7.9",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"otpauth": "^9.5.1",
|
||||
"qs": "^6.13.1",
|
||||
@@ -33,4 +40,4 @@
|
||||
"overrides": {
|
||||
"moment-jalaali": "^0.10.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · Settings</title>
|
||||
<title>Settings</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
|
||||
@@ -2,24 +2,16 @@ import axios from 'axios';
|
||||
import qs from 'qs';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
||||
// Public CSRF endpoint — works pre-login (the panel-scoped
|
||||
// /panel/csrf-token sits behind checkLogin and would 401 a fresh
|
||||
// login page that hasn't authenticated yet).
|
||||
const CSRF_TOKEN_PATH = '/csrf-token';
|
||||
|
||||
// Cached session CSRF token. The legacy panel injects it via a
|
||||
// <meta name="csrf-token"> tag rendered by Go; the new SPA pages
|
||||
// fetch it once from /panel/csrf-token instead. Module-level so
|
||||
// every axios POST sees the latest value.
|
||||
let csrfToken = null;
|
||||
let csrfFetchPromise = null;
|
||||
let sessionExpired = false;
|
||||
|
||||
function readMetaToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
||||
}
|
||||
|
||||
// Fetch the token via a bare fetch() (not axios) so the call doesn't
|
||||
// recurse through this same interceptor.
|
||||
async function fetchCsrfToken() {
|
||||
try {
|
||||
const basePath = window.X_UI_BASE_PATH;
|
||||
@@ -91,19 +83,12 @@ export function setupAxios() {
|
||||
async (error) => {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
// 401 → session is gone. In production, the panel routes
|
||||
// are gated by Go's checkLogin which redirects to base_path
|
||||
// serving the login page; a reload is enough. In dev, Vite
|
||||
// serves /index.html directly at "/", so a reload would put
|
||||
// the user right back on the dashboard and the interceptor
|
||||
// would loop. Navigate to the dev login entry instead.
|
||||
if (import.meta.env.DEV) {
|
||||
if (!sessionExpired) {
|
||||
sessionExpired = true;
|
||||
const basePath = window.X_UI_BASE_PATH || '/';
|
||||
window.location.href = `${basePath}login.html`;
|
||||
} else {
|
||||
window.location.reload();
|
||||
window.location.replace(basePath);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
return new Promise(() => { });
|
||||
}
|
||||
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
|
||||
const cfg = error.config;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -45,7 +46,7 @@ const tabs = computed(() => [
|
||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
|
||||
{ key: 'logout', icon: 'logout', title: t('logout') },
|
||||
]);
|
||||
|
||||
const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
|
||||
@@ -55,7 +56,12 @@ const drawerOpen = ref(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')) {
|
||||
window.open(key);
|
||||
} else {
|
||||
|
||||
185
frontend/src/components/JsonEditor.vue
Normal file
185
frontend/src/components/JsonEditor.vue
Normal file
@@ -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 },
|
||||
yTickStep: { type: Number, default: 25 },
|
||||
tickCountX: { type: Number, default: 4 },
|
||||
paddingLeft: { type: Number, default: 32 },
|
||||
paddingLeft: { type: Number, default: 56 },
|
||||
paddingRight: { type: Number, default: 6 },
|
||||
paddingTop: { type: Number, default: 6 },
|
||||
paddingBottom: { type: Number, default: 20 },
|
||||
|
||||
@@ -36,7 +36,9 @@ export function useNodeList() {
|
||||
return n != null && n.enable && n.status === 'online';
|
||||
}
|
||||
|
||||
const hasActive = computed(() => nodes.value.some((n) => n.enable));
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
return { nodes, fetched, refresh, byId, nameFor, isOnline };
|
||||
return { nodes, fetched, refresh, byId, nameFor, isOnline, hasActive };
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ 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) {
|
||||
|
||||
@@ -5,9 +5,11 @@ 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 InboundsPage from '@/pages/inbounds/InboundsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
|
||||
@@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
|
||||
// stored theme to <body>/<html> before Vue mounts.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import IndexPage from '@/pages/index/IndexPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
|
||||
@@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
|
||||
// stored theme to <body>/<html> before Vue renders anything.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import LoginPage from '@/pages/login/LoginPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
// Toasts attach to a #message div the page provides — keeps theme
|
||||
// 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 '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import NodesPage from '@/pages/nodes/NodesPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
|
||||
@@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
|
||||
// stored theme to <body>/<html> before Vue mounts.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import SettingsPage from '@/pages/settings/SettingsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
|
||||
@@ -5,9 +5,11 @@ 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 XrayPage from '@/pages/xray/XrayPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
|
||||
@@ -70,6 +70,10 @@ export class DBInbound {
|
||||
return this.protocol === Protocols.WIREGUARD;
|
||||
}
|
||||
|
||||
get isHysteria() {
|
||||
return this.protocol === Protocols.HYSTERIA;
|
||||
}
|
||||
|
||||
get address() {
|
||||
let address = location.hostname;
|
||||
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { ObjectUtil, RandomUtil, Base64, NumberFormatter, SizeFormatter, Wireguard } from '@/utils';
|
||||
import { getRandomRealityTarget } from '@/models/reality-targets';
|
||||
|
||||
export const Protocols = {
|
||||
VMESS: 'vmess',
|
||||
@@ -687,8 +688,9 @@ export class HysteriaMasquerade extends XrayCommonClass {
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
const type = ['proxy', 'file', 'string'].includes(json.type) ? json.type : 'proxy';
|
||||
return new HysteriaMasquerade(
|
||||
json.type,
|
||||
type,
|
||||
json.dir,
|
||||
json.url,
|
||||
json.rewriteHost,
|
||||
@@ -896,9 +898,7 @@ export class RealityStreamSettings extends XrayCommonClass {
|
||||
super();
|
||||
// If target/serverNames are not provided, use random values
|
||||
if (!target && !serverNames) {
|
||||
const randomTarget = typeof getRandomRealityTarget !== 'undefined'
|
||||
? getRandomRealityTarget()
|
||||
: { target: 'www.amazon.com:443', sni: 'www.amazon.com,amazon.com' };
|
||||
const randomTarget = getRandomRealityTarget();
|
||||
target = randomTarget.target;
|
||||
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 = [
|
||||
"sessionPlacement", "sessionKey",
|
||||
"seqPlacement", "seqKey",
|
||||
@@ -2967,37 +2971,45 @@ Inbound.HysteriaSettings.Hysteria = class extends Inbound.ClientBase {
|
||||
Inbound.TunnelSettings = class extends Inbound.Settings {
|
||||
constructor(
|
||||
protocol,
|
||||
address,
|
||||
port,
|
||||
rewriteAddress,
|
||||
rewritePort,
|
||||
portMap = [],
|
||||
network = 'tcp,udp',
|
||||
allowedNetwork = 'tcp,udp',
|
||||
followRedirect = false
|
||||
) {
|
||||
super(protocol);
|
||||
this.address = address;
|
||||
this.port = port;
|
||||
this.rewriteAddress = rewriteAddress;
|
||||
this.rewritePort = rewritePort;
|
||||
this.portMap = portMap;
|
||||
this.network = network;
|
||||
this.allowedNetwork = allowedNetwork;
|
||||
this.followRedirect = followRedirect;
|
||||
}
|
||||
|
||||
addPortMap(port = '', target = '') {
|
||||
this.portMap.push({ name: port, value: target });
|
||||
}
|
||||
|
||||
removePortMap(index) {
|
||||
this.portMap.splice(index, 1);
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new Inbound.TunnelSettings(
|
||||
Protocols.TUNNEL,
|
||||
json.address,
|
||||
json.port,
|
||||
json.rewriteAddress,
|
||||
json.rewritePort,
|
||||
XrayCommonClass.toHeaders(json.portMap),
|
||||
json.network,
|
||||
json.allowedNetwork,
|
||||
json.followRedirect,
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
address: this.address,
|
||||
port: this.port,
|
||||
rewriteAddress: this.rewriteAddress,
|
||||
rewritePort: this.rewritePort,
|
||||
portMap: XrayCommonClass.toV2Headers(this.portMap, false),
|
||||
network: this.network,
|
||||
allowedNetwork: this.allowedNetwork,
|
||||
followRedirect: this.followRedirect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export class AllSetting {
|
||||
this.webKeyFile = "";
|
||||
this.webBasePath = "/";
|
||||
this.sessionMaxAge = 360;
|
||||
this.trustedProxyCIDRs = "127.0.0.1/32,::1/128";
|
||||
this.pageSize = 25;
|
||||
this.expireDiff = 0;
|
||||
this.trafficDiff = 0;
|
||||
@@ -56,6 +57,7 @@ export class AllSetting {
|
||||
this.subUpdates = 12;
|
||||
this.subEncrypt = true;
|
||||
this.subShowInfo = true;
|
||||
this.subEmailInRemark = true;
|
||||
this.subURI = "";
|
||||
this.subJsonURI = "";
|
||||
this.subClashURI = "";
|
||||
@@ -87,6 +89,12 @@ export class AllSetting {
|
||||
this.ldapDefaultTotalGB = 0;
|
||||
this.ldapDefaultExpiryDays = 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) {
|
||||
return
|
||||
@@ -97,4 +105,4 @@ export class AllSetting {
|
||||
equals(other) {
|
||||
return ObjectUtil.equals(this, other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import {
|
||||
KeyOutlined,
|
||||
ReloadOutlined,
|
||||
CopyOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
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 { HttpUtil, ClipboardManager } from '@/utils/index.js';
|
||||
import { sections } from './endpoints.js';
|
||||
import { sections as allSections } from './endpoints.js';
|
||||
import EndpointSection from './EndpointSection.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
import CodeBlock from './CodeBlock.vue';
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
const settingsHref = `${basePath}panel/settings#security`;
|
||||
|
||||
const apiToken = ref('');
|
||||
const tokenLoading = ref(false);
|
||||
const tokenRotating = ref(false);
|
||||
const tokenVisible = ref(false);
|
||||
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`;
|
||||
|
||||
async function loadApiToken() {
|
||||
tokenLoading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/getApiToken');
|
||||
if (msg?.success) apiToken.value = msg.obj || '';
|
||||
} finally {
|
||||
tokenLoading.value = false;
|
||||
}
|
||||
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 regenerateApiToken() {
|
||||
Modal.confirm({
|
||||
title: t('pages.nodes.regenerateConfirm'),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
tokenRotating.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
|
||||
if (msg?.success) {
|
||||
apiToken.value = msg.obj || '';
|
||||
message.success(t('success'));
|
||||
}
|
||||
} finally {
|
||||
tokenRotating.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
function toggleSection(id) {
|
||||
const s = new Set(collapsedSections.value);
|
||||
if (s.has(id)) s.delete(id); else s.add(id);
|
||||
collapsedSections.value = s;
|
||||
}
|
||||
|
||||
async function copyApiToken() {
|
||||
if (!apiToken.value) return;
|
||||
const ok = await ClipboardManager.copy(apiToken.value);
|
||||
if (ok) message.success(t('success'));
|
||||
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) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
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(() => {
|
||||
loadApiToken();
|
||||
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>
|
||||
|
||||
@@ -93,60 +156,81 @@ onMounted(() => {
|
||||
cookie, or with the <code>Authorization: Bearer <token></code> header below. Every endpoint
|
||||
returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted.
|
||||
</p>
|
||||
|
||||
</header>
|
||||
|
||||
<a-card class="token-card" size="small">
|
||||
<div class="token-card-head">
|
||||
<div class="token-card-title">
|
||||
<KeyOutlined />
|
||||
<span>API Token</span>
|
||||
<span>API Tokens</span>
|
||||
</div>
|
||||
<a-space size="small" wrap>
|
||||
<a-button size="small" @click="tokenVisible = !tokenVisible">
|
||||
<template #icon>
|
||||
<EyeInvisibleOutlined v-if="tokenVisible" />
|
||||
<EyeOutlined v-else />
|
||||
</template>
|
||||
{{ tokenVisible ? 'Hide' : 'Show' }}
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="!apiToken" @click="copyApiToken">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
Copy
|
||||
</a-button>
|
||||
<a-button size="small" danger :loading="tokenRotating" @click="regenerateApiToken">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
Regenerate
|
||||
</a-button>
|
||||
</a-space>
|
||||
<a-button type="primary" size="small" :href="settingsHref">
|
||||
Manage tokens
|
||||
</a-button>
|
||||
</div>
|
||||
<a-spin :spinning="tokenLoading" size="small">
|
||||
<pre
|
||||
class="token-value">{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}</pre>
|
||||
</a-spin>
|
||||
<p class="token-hint">
|
||||
Send it on every request as <code>Authorization: Bearer <token></code>. Token-authenticated
|
||||
callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately —
|
||||
running bots will need the new value.
|
||||
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">
|
||||
<pre class="code-block">{{ curlExample }}</pre>
|
||||
<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>
|
||||
<a v-for="s in sections" :key="s.id" class="toc-link" :href="`#${s.id}`"
|
||||
@click.prevent="scrollToSection(s.id)">
|
||||
{{ s.title }}
|
||||
</a>
|
||||
<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" />
|
||||
<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>
|
||||
@@ -194,20 +278,25 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
margin-bottom: 18px;
|
||||
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: 26px;
|
||||
font-weight: 700;
|
||||
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.6;
|
||||
line-height: 1.65;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -231,7 +320,8 @@ onMounted(() => {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 10px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.token-card-title {
|
||||
@@ -242,18 +332,6 @@ onMounted(() => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.token-hint {
|
||||
margin: 10px 0 0;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
@@ -275,35 +353,110 @@ onMounted(() => {
|
||||
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: center;
|
||||
gap: 8px 14px;
|
||||
align-items: flex-start;
|
||||
gap: 8px 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toc-label {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
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 {
|
||||
color: #1677ff;
|
||||
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;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toc-link:hover {
|
||||
color: #4096ff;
|
||||
text-decoration: underline;
|
||||
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>
|
||||
|
||||
@@ -312,28 +465,97 @@ 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);
|
||||
}
|
||||
|
||||
body.dark .token-value,
|
||||
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: rgba(255, 255, 255, 0.04);
|
||||
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>
|
||||
|
||||
174
frontend/src/pages/api-docs/CodeBlock.vue
Normal file
174
frontend/src/pages/api-docs/CodeBlock.vue
Normal file
@@ -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>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { methodColors } from './endpoints.js';
|
||||
import { methodColors, safeInlineHtml } from './endpoints.js';
|
||||
import CodeBlock from './CodeBlock.vue';
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: Object, required: true },
|
||||
@@ -24,7 +25,7 @@ const paramColumns = [
|
||||
<code class="endpoint-path">{{ endpoint.path }}</code>
|
||||
</div>
|
||||
|
||||
<p v-if="endpoint.summary" class="endpoint-summary">{{ endpoint.summary }}</p>
|
||||
<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>
|
||||
@@ -33,27 +34,39 @@ const paramColumns = [
|
||||
|
||||
<div v-if="endpoint.body" class="endpoint-block">
|
||||
<div class="block-label">Request body</div>
|
||||
<a-typography-paragraph :copyable="{ text: endpoint.body }">
|
||||
<pre class="code-block">{{ endpoint.body }}</pre>
|
||||
</a-typography-paragraph>
|
||||
<CodeBlock :code="endpoint.body" lang="json" />
|
||||
</div>
|
||||
|
||||
<div v-if="endpoint.response" class="endpoint-block">
|
||||
<div class="block-label">Response</div>
|
||||
<a-typography-paragraph :copyable="{ text: endpoint.response }">
|
||||
<pre class="code-block">{{ endpoint.response }}</pre>
|
||||
</a-typography-paragraph>
|
||||
<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: 12px 0;
|
||||
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.15);
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
@@ -64,38 +77,52 @@ const paramColumns = [
|
||||
}
|
||||
|
||||
.method-tag {
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 60px;
|
||||
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: 13px;
|
||||
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.65);
|
||||
line-height: 1.55;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
line-height: 1.6;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.endpoint-block {
|
||||
margin-top: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
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);
|
||||
@@ -112,12 +139,29 @@ const paramColumns = [
|
||||
</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.7);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
body.dark .block-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
body.dark .error-label {
|
||||
color: #ff7b72;
|
||||
}
|
||||
|
||||
body.dark .code-block {
|
||||
|
||||
@@ -1,16 +1,57 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
DownOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import EndpointRow from './EndpointRow.vue';
|
||||
import { safeInlineHtml } from './endpoints.js';
|
||||
|
||||
defineProps({
|
||||
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">
|
||||
<h2 class="section-title">{{ section.title }}</h2>
|
||||
<p v-if="section.description" class="section-description">{{ section.description }}</p>
|
||||
<div class="endpoints">
|
||||
<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>
|
||||
@@ -19,24 +60,89 @@ defineProps({
|
||||
<style scoped>
|
||||
.api-section {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
scroll-margin-top: 16px;
|
||||
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: 600;
|
||||
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: 6px 0 14px;
|
||||
margin: 12px 0 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.55;
|
||||
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 {
|
||||
@@ -47,19 +153,40 @@ defineProps({
|
||||
<style>
|
||||
body.dark .api-section {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
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.08);
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
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: 'auth',
|
||||
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/*.',
|
||||
@@ -17,11 +42,14 @@ export const sections = [
|
||||
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: 'GET',
|
||||
method: 'POST',
|
||||
path: '/logout',
|
||||
summary: 'Clear the session cookie. Redirects back to the login page; not useful from non-browser clients.',
|
||||
summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.',
|
||||
response: '{\n "success": true\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -41,9 +69,9 @@ export const sections = [
|
||||
|
||||
{
|
||||
id: 'inbounds',
|
||||
title: 'Inbounds API',
|
||||
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 X-Forwarded-Host / X-Forwarded-Proto, so callers behind a reverse proxy get the correct external host in returned URLs.',
|
||||
'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',
|
||||
@@ -67,6 +95,7 @@ export const sections = [
|
||||
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',
|
||||
@@ -75,6 +104,7 @@ export const sections = [
|
||||
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',
|
||||
@@ -82,6 +112,8 @@ export const sections = [
|
||||
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',
|
||||
@@ -161,6 +193,14 @@ export const sections = [
|
||||
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',
|
||||
@@ -209,6 +249,7 @@ export const sections = [
|
||||
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',
|
||||
@@ -256,7 +297,7 @@ export const sections = [
|
||||
|
||||
{
|
||||
id: 'server',
|
||||
title: 'Server API',
|
||||
title: 'Server',
|
||||
description:
|
||||
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
|
||||
endpoints: [
|
||||
@@ -264,6 +305,7 @@ export const sections = [
|
||||
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',
|
||||
@@ -278,7 +320,36 @@ export const sections = [
|
||||
path: '/panel/api/server/history/:metric/:bucket',
|
||||
summary: 'Aggregated time-series for one metric. Returns an array of {t, v} samples covering the last ~6 hours.',
|
||||
params: [
|
||||
{ name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem | swap | netIn | netOut | tcpCount | udpCount | load1 | online.' },
|
||||
{ name: '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.' },
|
||||
],
|
||||
},
|
||||
@@ -286,6 +357,7 @@ export const sections = [
|
||||
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',
|
||||
@@ -295,7 +367,8 @@ export const sections = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getConfigJson',
|
||||
summary: 'Return the assembled Xray config that’s currently running on this host.',
|
||||
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',
|
||||
@@ -306,36 +379,45 @@ export const sections = [
|
||||
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 a new VLESS encryption keypair.',
|
||||
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',
|
||||
@@ -354,6 +436,10 @@ export const sections = [
|
||||
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',
|
||||
@@ -366,11 +452,12 @@ export const sections = [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/logs/:count',
|
||||
summary: 'Return the last N lines of the panel’s own log.',
|
||||
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',
|
||||
@@ -378,24 +465,38 @@ export const sections = [
|
||||
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. Body picks the algorithm.',
|
||||
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 API',
|
||||
title: 'Nodes',
|
||||
description:
|
||||
'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.',
|
||||
endpoints: [
|
||||
@@ -403,6 +504,7 @@ export const sections = [
|
||||
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',
|
||||
@@ -422,10 +524,11 @@ export const sections = [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/update/:id',
|
||||
summary: 'Replace a node’s connection details. Same body shape as /add.',
|
||||
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',
|
||||
@@ -448,6 +551,8 @@ export const sections = [
|
||||
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',
|
||||
@@ -463,16 +568,16 @@ export const sections = [
|
||||
summary: 'Aggregated metric history for a node — same shape as /server/history, scoped to one node.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
{ name: 'metric', in: 'path', type: 'string', desc: 'Metric key (cpu, mem, netIn, …).' },
|
||||
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds.' },
|
||||
{ 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: 'customGeo',
|
||||
title: 'Custom Geo API',
|
||||
id: 'custom-geo',
|
||||
title: 'Custom Geo',
|
||||
description:
|
||||
'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
|
||||
endpoints: [
|
||||
@@ -531,12 +636,268 @@ export const sections = [
|
||||
description: 'Operations that interact with the configured Telegram bot.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
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 = {
|
||||
@@ -545,4 +906,5 @@ export const methodColors = {
|
||||
PUT: 'orange',
|
||||
PATCH: 'orange',
|
||||
DELETE: 'red',
|
||||
WS: 'purple',
|
||||
};
|
||||
|
||||
@@ -32,6 +32,8 @@ const props = defineProps({
|
||||
lastOnlineMap: { type: Object, default: () => ({}) },
|
||||
isDarkTheme: { type: Boolean, default: false },
|
||||
pageSize: { type: Number, default: 0 },
|
||||
totalClientCount: { type: Number, default: 0 },
|
||||
statsVersion: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -62,7 +64,11 @@ watch([clients, () => props.pageSize], () => {
|
||||
});
|
||||
|
||||
// === 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(() => {
|
||||
void props.statsVersion;
|
||||
const m = new Map();
|
||||
for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
|
||||
return m;
|
||||
@@ -138,7 +144,7 @@ function statsExpColor(email) {
|
||||
return PURPLE;
|
||||
}
|
||||
|
||||
const isRemovable = computed(() => clients.value.length > 1);
|
||||
const isRemovable = computed(() => (props.totalClientCount || clients.value.length) > 1);
|
||||
|
||||
function totalGbDisplay(client) {
|
||||
if (!client.totalGB || client.totalGB <= 0) return '';
|
||||
@@ -216,6 +222,14 @@ watch(clients, (list) => {
|
||||
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;
|
||||
@@ -432,6 +446,9 @@ function confirmBulkDelete() {
|
||||
<span class="client-email">{{ client.email }}</span>
|
||||
</a-tooltip>
|
||||
<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"
|
||||
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
@@ -458,52 +475,55 @@ function confirmBulkDelete() {
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="client.comment && client.comment.trim()" class="client-comment-line">
|
||||
{{ client.comment.length > 80 ? client.comment.substring(0, 77) + '…' : client.comment }}
|
||||
</div>
|
||||
|
||||
<div class="client-card-foot">
|
||||
<a-modal :open="!!statsClient" :footer="null" :width="360" centered
|
||||
:title="statsClient ? statsClient.email || t('info') : ''" @cancel="closeStats">
|
||||
<div v-if="statsClient" class="client-card-foot">
|
||||
<div v-if="statsClient.comment && statsClient.comment.trim()" class="client-comment-line">
|
||||
{{ statsClient.comment }}
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
||||
<a-tag :color="clientStatsColor(client.email)">
|
||||
{{ SizeFormatter.sizeFormat(getSum(client.email)) }} /
|
||||
<InfinityIcon v-if="isUnlimitedTotal(client)" />
|
||||
<template v-else>{{ totalGbDisplay(client) }}</template>
|
||||
<a-tag :color="clientStatsColor(statsClient.email)">
|
||||
{{ SizeFormatter.sizeFormat(getSum(statsClient.email)) }} /
|
||||
<InfinityIcon v-if="isUnlimitedTotal(statsClient)" />
|
||||
<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(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
||||
<a-tag v-if="isUnlimitedTotal(statsClient)" 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 v-else :color="isClientDepleted(statsClient.email) ? 'red' : ''">
|
||||
{{ SizeFormatter.sizeFormat(getRem(statsClient.email)) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(statsClient.email)) }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('online') }}</span>
|
||||
<a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
|
||||
<a-tag v-if="statsClient.enable && isClientOnline(statsClient.email)" color="green">{{ t('online') }}</a-tag>
|
||||
<a-tag v-else>{{ t('offline') }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
|
||||
<a-tag v-if="client.expiryTime > 0" :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
|
||||
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
|
||||
<a-tag v-if="statsClient.expiryTime > 0"
|
||||
:color="ColorUtils.userExpiryColor(expireDiff, statsClient, isDarkTheme)">
|
||||
{{ IntlUtil.formatRelativeTime(statsClient.expiryTime) }}
|
||||
</a-tag>
|
||||
<a-tag v-else-if="client.expiryTime < 0" color="green">
|
||||
{{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
|
||||
<a-tag v-else-if="statsClient.expiryTime < 0" color="green">
|
||||
{{ -statsClient.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
|
||||
</a-tag>
|
||||
<a-tag v-else color="purple">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"
|
||||
|
||||
185
frontend/src/pages/inbounds/CopyClientsModal.vue
Normal file
185
frontend/src/pages/inbounds/CopyClientsModal.vue
Normal file
@@ -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,
|
||||
Wireguard,
|
||||
} from '@/utils';
|
||||
import { getRandomRealityTarget } from '@/models/reality-targets';
|
||||
import {
|
||||
Inbound,
|
||||
Protocols,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
import { DBInbound } from '@/models/dbinbound.js';
|
||||
import FinalMaskForm from '@/components/FinalMaskForm.vue';
|
||||
import DateTimePicker from '@/components/DateTimePicker.vue';
|
||||
import JsonEditor from '@/components/JsonEditor.vue';
|
||||
import { useNodeList } from '@/composables/useNodeList.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -69,6 +71,7 @@ const inbound = ref(null);
|
||||
const dbForm = ref(null);
|
||||
const saving = ref(false);
|
||||
const advancedJson = ref({ stream: '', sniffing: '', settings: '' });
|
||||
const activeTabKey = ref('basic');
|
||||
// Cached default cert/key paths from /panel/setting/defaultSettings —
|
||||
// powers the "Set default cert" button on the TLS form.
|
||||
const defaultCert = ref('');
|
||||
@@ -240,9 +243,60 @@ watch(() => props.open, (next) => {
|
||||
dbForm.value = freshDbForm();
|
||||
primeAdvancedJson();
|
||||
}
|
||||
activeTabKey.value = 'basic';
|
||||
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.
|
||||
function onProtocolChange(next) {
|
||||
if (props.mode === 'edit' || !inbound.value) return;
|
||||
@@ -339,11 +393,9 @@ function clearMldsa65() {
|
||||
inbound.value.stream.reality.settings.mldsa65Verify = '';
|
||||
}
|
||||
|
||||
// Reality target/SNI randomizer — only available if the helper is loaded
|
||||
function randomizeRealityTarget() {
|
||||
if (!inbound.value?.stream?.reality) return;
|
||||
if (typeof window.getRandomRealityTarget !== 'function') return;
|
||||
const t = window.getRandomRealityTarget();
|
||||
const t = getRandomRealityTarget();
|
||||
inbound.value.stream.reality.target = t.target;
|
||||
inbound.value.stream.reality.serverNames = t.sni;
|
||||
}
|
||||
@@ -393,16 +445,29 @@ async function fetchDefaultCertSettings() {
|
||||
}
|
||||
|
||||
// === VLESS encryption helpers =======================================
|
||||
// `xray vlessenc` returns both X25519 and ML-KEM-768 variants every
|
||||
// call; the user clicks one of two buttons to pick which block goes
|
||||
// into decryption/encryption.
|
||||
async function getNewVlessEnc(authLabel) {
|
||||
if (!authLabel || !inbound.value?.settings) return;
|
||||
// `xray vlessenc` returns both X25519 and ML-KEM-768 auth variants every
|
||||
// call; the user clicks one button to pick which block goes into
|
||||
// decryption/encryption. Both generated strings share the same hybrid
|
||||
// mlkem768x25519plus prefix; the auth choice is the final key block.
|
||||
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;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
|
||||
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;
|
||||
inbound.value.settings.decryption = block.decryption;
|
||||
inbound.value.settings.encryption = block.encryption;
|
||||
@@ -417,6 +482,17 @@ function clearVlessEnc() {
|
||||
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 =========================
|
||||
function onSSMethodChange() {
|
||||
inbound.value.settings.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method);
|
||||
@@ -549,7 +625,7 @@ watch(
|
||||
<template>
|
||||
<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">
|
||||
<a-tabs v-if="inbound && dbForm" default-active-key="basic">
|
||||
<a-tabs v-if="inbound && dbForm" v-model:active-key="activeTabKey">
|
||||
<!-- ============================== BASICS ============================== -->
|
||||
<a-tab-pane key="basic" :tab="t('pages.xray.basicTemplate')">
|
||||
<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-input v-model:value="dbForm.remark" />
|
||||
</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'"
|
||||
:placeholder="t('pages.inbounds.localPanel')" allow-clear>
|
||||
<a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option>
|
||||
@@ -604,10 +680,7 @@ watch(
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- ============================== PROTOCOL ============================== -->
|
||||
<!-- TUN has no per-protocol form yet (interface/mtu/gateway live in
|
||||
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')">
|
||||
<a-tab-pane key="protocol" :tab="t('pages.inbounds.protocol')">
|
||||
<!-- Multi-user inbounds: in add mode embed the first client form,
|
||||
in edit mode show a count summary. -->
|
||||
<template v-if="isMultiUser">
|
||||
@@ -731,14 +804,17 @@ watch(
|
||||
</a-form-item>
|
||||
<a-form-item label=" ">
|
||||
<a-space :size="8" wrap>
|
||||
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('X25519, not Post-Quantum')">
|
||||
X25519
|
||||
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('x25519')">
|
||||
X25519 auth
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('ML-KEM-768, Post-Quantum')">
|
||||
ML-KEM-768
|
||||
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('mlkem768')">
|
||||
ML-KEM-768 auth
|
||||
</a-button>
|
||||
<a-button danger @click="clearVlessEnc">Clear</a-button>
|
||||
</a-space>
|
||||
<a-typography-text type="secondary" class="vless-auth-state">
|
||||
Selected: {{ selectedVlessAuth }}
|
||||
</a-typography-text>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
@@ -817,24 +893,126 @@ watch(
|
||||
<!-- Tunnel -->
|
||||
<a-form v-if="protocol === Protocols.TUNNEL" :colon="false" :label-col="{ sm: { span: 8 } }"
|
||||
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
||||
<a-form-item label="Address">
|
||||
<a-input v-model:value="inbound.settings.address" />
|
||||
<a-form-item label="Rewrite address">
|
||||
<a-input v-model:value="inbound.settings.rewriteAddress" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Destination port">
|
||||
<a-input-number v-model:value="inbound.settings.port" :min="1" :max="65535" />
|
||||
<a-form-item label="Rewrite port">
|
||||
<a-input-number v-model:value="inbound.settings.rewritePort" :min="0" :max="65535" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Network">
|
||||
<a-select v-model:value="inbound.settings.network">
|
||||
<a-form-item label="Allowed 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">TCP</a-select-option>
|
||||
<a-select-option value="udp">UDP</a-select-option>
|
||||
</a-select>
|
||||
</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-switch v-model:checked="inbound.settings.followRedirect" />
|
||||
</a-form-item>
|
||||
</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 -->
|
||||
<a-form v-if="protocol === Protocols.WIREGUARD" :colon="false" :label-col="{ sm: { span: 8 } }"
|
||||
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
|
||||
@@ -1644,6 +1822,98 @@ watch(
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</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>
|
||||
|
||||
<!-- ====== FinalMask (TCP/UDP masks + QUIC params) ====== -->
|
||||
@@ -1687,16 +1957,13 @@ watch(
|
||||
class="mb-12" />
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="settings (clients, encryption, fallbacks, …)">
|
||||
<a-textarea v-model:value="advancedJson.settings" :auto-size="{ minRows: 10, maxRows: 24 }"
|
||||
spellcheck="false" class="json-editor" />
|
||||
<JsonEditor v-model:value="advancedJson.settings" min-height="280px" max-height="520px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="streamSettings">
|
||||
<a-textarea v-model:value="advancedJson.stream" :auto-size="{ minRows: 10, maxRows: 24 }" spellcheck="false"
|
||||
class="json-editor" />
|
||||
<JsonEditor v-model:value="advancedJson.stream" min-height="280px" max-height="520px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="sniffing (overrides the Sniffing tab when set)">
|
||||
<a-textarea v-model:value="advancedJson.sniffing" :auto-size="{ minRows: 6, maxRows: 16 }"
|
||||
spellcheck="false" class="json-editor" />
|
||||
<JsonEditor v-model:value="advancedJson.sniffing" min-height="180px" max-height="360px" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
@@ -1741,9 +2008,9 @@ watch(
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.json-editor {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
.vless-auth-state {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.client-summary {
|
||||
|
||||
@@ -585,19 +585,50 @@ const showSubscriptionTab = computed(
|
||||
</tbody>
|
||||
</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 -->
|
||||
<dl v-if="inbound.protocol === Protocols.TUNNEL" class="info-list info-list-block">
|
||||
<div class="info-row">
|
||||
<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 class="info-row">
|
||||
<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 class="info-row">
|
||||
<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 class="info-row">
|
||||
<dt>FollowRedirect</dt>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -49,6 +49,8 @@ const props = defineProps({
|
||||
// Map node id -> node row, supplied by the parent page so each
|
||||
// inbound row can render its node name without an extra fetch.
|
||||
nodesById: { type: Map, default: () => new Map() },
|
||||
hasActiveNode: { type: Boolean, default: false },
|
||||
statsVersion: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -67,9 +69,29 @@ const emit = defineEmits([
|
||||
]);
|
||||
|
||||
// ============ Toolbar / search & filter =============================
|
||||
const enableFilter = ref(false);
|
||||
const searchKey = ref('');
|
||||
const filterBy = ref('');
|
||||
const FILTER_STATE_KEY = 'inboundsFilterState';
|
||||
const savedFilterState = (() => {
|
||||
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.
|
||||
function onToggleFilter() {
|
||||
@@ -77,6 +99,35 @@ function onToggleFilter() {
|
||||
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 =============================
|
||||
// Mirrors the legacy logic: when searching, keep inbounds that match
|
||||
// anywhere (deep search); when filtering, keep inbounds that have at
|
||||
@@ -99,7 +150,7 @@ function projectInbound(dbInbound, predicate) {
|
||||
|
||||
const visibleInbounds = computed(() => {
|
||||
if (enableFilter.value) {
|
||||
if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds];
|
||||
if (ObjectUtil.isEmpty(filterBy.value)) return applySecondaryFilters([...props.dbInbounds]);
|
||||
const out = [];
|
||||
for (const dbInbound of props.dbInbounds) {
|
||||
const c = props.clientCount[dbInbound.id];
|
||||
@@ -107,15 +158,65 @@ const visibleInbounds = computed(() => {
|
||||
const list = c[filterBy.value];
|
||||
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 = [];
|
||||
for (const dbInbound of props.dbInbounds) {
|
||||
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
|
||||
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
|
||||
}
|
||||
return out;
|
||||
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 =================================================
|
||||
@@ -128,23 +229,23 @@ const hasAnyRemark = computed(() =>
|
||||
|
||||
const desktopColumns = computed(() => {
|
||||
const cols = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 },
|
||||
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
|
||||
{ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 },
|
||||
sortableCol({ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 }, 'id'),
|
||||
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 60 },
|
||||
sortableCol({ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, 'enable'),
|
||||
];
|
||||
if (hasAnyRemark.value) {
|
||||
cols.push({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 });
|
||||
cols.push(sortableCol({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 }, 'remark'));
|
||||
}
|
||||
if (props.nodesById.size > 0) {
|
||||
cols.push({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 });
|
||||
if (props.hasActiveNode) {
|
||||
cols.push(sortableCol({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 }, 'node'));
|
||||
}
|
||||
cols.push(
|
||||
{ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 },
|
||||
{ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 },
|
||||
{ title: t('clients'), key: 'clients', align: 'left', width: 50 },
|
||||
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 },
|
||||
{ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 },
|
||||
{ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 },
|
||||
sortableCol({ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 }, 'port'),
|
||||
sortableCol({ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, 'protocol'),
|
||||
sortableCol({ title: t('clients'), key: 'clients', align: 'left', width: 50 }, 'clients'),
|
||||
sortableCol({ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, 'traffic'),
|
||||
sortableCol({ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 }, 'allTimeInbound'),
|
||||
sortableCol({ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
|
||||
);
|
||||
return cols;
|
||||
});
|
||||
@@ -163,6 +264,14 @@ function isExpanded(id) {
|
||||
return expandedIds.value.has(id);
|
||||
}
|
||||
|
||||
const statsRecord = ref(null);
|
||||
function openStats(record) {
|
||||
statsRecord.value = record;
|
||||
}
|
||||
function closeStats() {
|
||||
statsRecord.value = null;
|
||||
}
|
||||
|
||||
// ============ Pagination ============================================
|
||||
function paginationFor(rows) {
|
||||
const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
|
||||
@@ -269,20 +378,35 @@ function showQrCodeMenu(dbInbound) {
|
||||
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
|
||||
<a-radio-button value="online">{{ t('online') }}</a-radio-button>
|
||||
</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>
|
||||
|
||||
<!-- ====================== Mobile: card list ======================= -->
|
||||
<div v-if="isMobile" class="inbound-cards">
|
||||
<div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
|
||||
|
||||
<div v-for="record in visibleInbounds" :key="record.id" class="inbound-card">
|
||||
<!-- Header: chevron (multi-user only) + remark + enable + actions -->
|
||||
<div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
|
||||
<!-- Header: chevron (multi-user only) + id + remark + info + enable + actions -->
|
||||
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
|
||||
<RightOutlined v-if="record.isMultiUser()" class="card-expand"
|
||||
:class="{ 'is-expanded': isExpanded(record.id) }" />
|
||||
<span class="card-id">#{{ record.id }}</span>
|
||||
<span class="tag-name">{{ record.remark }}</span>
|
||||
<div class="card-actions" @click.stop>
|
||||
<a-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-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<MoreOutlined class="row-action-trigger" @click.prevent />
|
||||
@@ -340,74 +464,12 @@ function showQrCodeMenu(dbInbound) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2-column labelled stat grid: protocol/port/node + traffic/clients/expiry -->
|
||||
<div class="card-stats">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
|
||||
<a-tag color="purple">{{ record.protocol }}</a-tag>
|
||||
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
|
||||
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
|
||||
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
|
||||
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
|
||||
</template>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.port') }}</span>
|
||||
<a-tag>{{ record.port }}</a-tag>
|
||||
</div>
|
||||
<div v-if="nodesById.size > 0" class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.node') }}</span>
|
||||
<a-tag v-if="record.nodeId == null" color="default">
|
||||
{{ t('pages.inbounds.localPanel') }}
|
||||
</a-tag>
|
||||
<a-tag v-else-if="nodesById.get(record.nodeId)"
|
||||
:color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
|
||||
{{ nodesById.get(record.nodeId).name }}
|
||||
</a-tag>
|
||||
<a-tag v-else color="orange">#{{ record.nodeId }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
||||
<a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
|
||||
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
|
||||
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
|
||||
<InfinityIcon v-else />
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
|
||||
</div>
|
||||
<div v-if="clientCount[record.id]" class="stat-row">
|
||||
<span class="stat-label">{{ t('clients') }}</span>
|
||||
<a-tag color="green">{{ clientCount[record.id].clients }}</a-tag>
|
||||
<a-tag v-if="clientCount[record.id].online.length" color="blue">
|
||||
{{ clientCount[record.id].online.length }} {{ t('online') }}
|
||||
</a-tag>
|
||||
<a-tag v-if="clientCount[record.id].depleted.length" color="red">
|
||||
{{ clientCount[record.id].depleted.length }} {{ t('depleted') }}
|
||||
</a-tag>
|
||||
<a-tag v-if="clientCount[record.id].expiring.length" color="orange">
|
||||
{{ clientCount[record.id].expiring.length }} {{ t('depletingSoon') }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
|
||||
<a-tag v-if="record.expiryTime > 0"
|
||||
:color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)">
|
||||
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
|
||||
</a-tag>
|
||||
<a-tag v-else color="purple">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded client list (multi-user only) -->
|
||||
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
|
||||
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
|
||||
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
|
||||
:page-size="pageSize"
|
||||
: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)"
|
||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||
@@ -418,10 +480,77 @@ function showQrCodeMenu(dbInbound) {
|
||||
</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 ======================== -->
|
||||
<a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
|
||||
:pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
|
||||
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
|
||||
<a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
|
||||
:pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
|
||||
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')" @change="onTableChange">
|
||||
<!-- Per-inbound client list, expanded by clicking the row's
|
||||
default expand chevron. Hidden via row-class-name for
|
||||
non-multi-user inbounds (matches legacy behavior). -->
|
||||
@@ -429,6 +558,8 @@ function showQrCodeMenu(dbInbound) {
|
||||
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
|
||||
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
|
||||
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
|
||||
: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)"
|
||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||
@@ -440,59 +571,68 @@ function showQrCodeMenu(dbInbound) {
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- ============== Action dropdown ============== -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-dropdown :trigger="['click']">
|
||||
<MoreOutlined class="row-action-trigger" @click.prevent />
|
||||
<template #overlay>
|
||||
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
|
||||
<a-menu-item key="edit">
|
||||
<EditOutlined /> {{ t('edit') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
|
||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||
</a-menu-item>
|
||||
<template v-if="record.isMultiUser()">
|
||||
<a-menu-item key="addClient">
|
||||
<UserAddOutlined /> {{ t('pages.client.add') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="addBulkClient">
|
||||
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="copyClients">
|
||||
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetClients">
|
||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="export">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="subEnable" key="subs">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||
</a-menu-item>
|
||||
<div class="action-buttons">
|
||||
<a-button type="text" size="small" @click.prevent="emit('row-action', {key: 'edit', dbInbound: record})">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="text" size="small" @click.prevent>
|
||||
<template #icon>
|
||||
<MoreOutlined />
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-menu-item key="showInfo">
|
||||
<InfoCircleOutlined /> {{ t('info') }}
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<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>
|
||||
</template>
|
||||
<a-menu-item key="clipboard">
|
||||
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetTraffic">
|
||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="clone">
|
||||
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delete" class="danger-item">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<template v-if="record.isMultiUser()">
|
||||
<a-menu-item key="addClient">
|
||||
<UserAddOutlined /> {{ t('pages.client.add') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="addBulkClient">
|
||||
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="copyClients">
|
||||
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetClients">
|
||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="export">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="subEnable" key="subs">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-menu-item key="showInfo">
|
||||
<InfoCircleOutlined /> {{ t('info') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<a-menu-item key="clipboard">
|
||||
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetTraffic">
|
||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="clone">
|
||||
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delete" class="danger-item">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============== Enable switch (desktop) ============== -->
|
||||
@@ -520,8 +660,8 @@ function showQrCodeMenu(dbInbound) {
|
||||
<template v-else-if="column.key === 'protocol'">
|
||||
<div class="protocol-tags">
|
||||
<a-tag color="purple">{{ record.protocol }}</a-tag>
|
||||
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
|
||||
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
|
||||
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria">
|
||||
<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.isReality" color="blue">Reality</a-tag>
|
||||
</template>
|
||||
@@ -531,14 +671,14 @@ function showQrCodeMenu(dbInbound) {
|
||||
<!-- ============== Clients tag + popovers ============== -->
|
||||
<template v-else-if="column.key === 'clients'">
|
||||
<template v-if="clientCount[record.id]">
|
||||
<a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag>
|
||||
<a-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')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
|
||||
<a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
|
||||
<template #content>
|
||||
@@ -546,7 +686,7 @@ function showQrCodeMenu(dbInbound) {
|
||||
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
|
||||
<a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
|
||||
}}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
|
||||
@@ -555,7 +695,7 @@ function showQrCodeMenu(dbInbound) {
|
||||
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
|
||||
<a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
|
||||
}}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
|
||||
@@ -564,7 +704,7 @@ function showQrCodeMenu(dbInbound) {
|
||||
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
|
||||
<a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
|
||||
</a-popover>
|
||||
</template>
|
||||
</template>
|
||||
@@ -630,7 +770,14 @@ function showQrCodeMenu(dbInbound) {
|
||||
}
|
||||
|
||||
.filter-bar.mobile>* {
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.protocol-tags {
|
||||
@@ -639,6 +786,10 @@ function showQrCodeMenu(dbInbound) {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.client-count-tag {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -21,6 +21,7 @@ import InboundList from './InboundList.vue';
|
||||
import InboundFormModal from './InboundFormModal.vue';
|
||||
import ClientFormModal from './ClientFormModal.vue';
|
||||
import ClientBulkModal from './ClientBulkModal.vue';
|
||||
import CopyClientsModal from './CopyClientsModal.vue';
|
||||
import InboundInfoModal from './InboundInfoModal.vue';
|
||||
import QrCodeModal from './QrCodeModal.vue';
|
||||
import TextModal from '@/components/TextModal.vue';
|
||||
@@ -44,6 +45,7 @@ const {
|
||||
ipLimitEnable,
|
||||
remarkModel,
|
||||
lastOnlineMap,
|
||||
statsVersion,
|
||||
refresh,
|
||||
fetchDefaultSettings,
|
||||
applyTrafficEvent,
|
||||
@@ -65,7 +67,7 @@ useWebSocket({
|
||||
const { isMobile } = useMediaQuery();
|
||||
// Node list lives on the central panel; the Inbounds page consumes
|
||||
// the id→node map for the new "Node" column. Fetched once on mount.
|
||||
const { byId: nodesById } = useNodeList();
|
||||
const { byId: nodesById, hasActive: hasActiveNode } = useNodeList();
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
@@ -88,6 +90,8 @@ const clientIndex = ref(null);
|
||||
|
||||
const bulkOpen = ref(false);
|
||||
const bulkDbInbound = ref(null);
|
||||
const copyOpen = ref(false);
|
||||
const copyDbInbound = ref(null);
|
||||
|
||||
// === Info / QR-code modals ===========================================
|
||||
const infoOpen = ref(false);
|
||||
@@ -393,7 +397,7 @@ function confirmResetTraffic(dbInbound) {
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
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();
|
||||
},
|
||||
});
|
||||
@@ -515,10 +519,8 @@ function onRowAction({ key, dbInbound }) {
|
||||
exportInboundClipboard(dbInbound);
|
||||
break;
|
||||
case 'copyClients':
|
||||
// Copy-clients-from-inbound is a tiny dedicated modal in legacy
|
||||
// (lets you tick clients to copy across inbounds). Defer to a
|
||||
// future commit — surface a friendly message for now.
|
||||
message.info('Copy clients across inbounds — coming soon');
|
||||
copyDbInbound.value = dbInbound;
|
||||
copyOpen.value = true;
|
||||
break;
|
||||
case 'delete':
|
||||
confirmDelete(dbInbound);
|
||||
@@ -646,7 +648,9 @@ function onRowAction({ key, dbInbound }) {
|
||||
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
|
||||
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
|
||||
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
|
||||
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh"
|
||||
: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"
|
||||
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
|
||||
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
|
||||
@@ -663,6 +667,8 @@ function onRowAction({ key, dbInbound }) {
|
||||
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
|
||||
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
|
||||
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
|
||||
<CopyClientsModal v-model:open="copyOpen" :db-inbound="copyDbInbound" :db-inbounds="dbInbounds"
|
||||
@saved="refresh" />
|
||||
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
|
||||
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
||||
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
|
||||
|
||||
@@ -98,7 +98,6 @@ watch(() => props.open, (next) => {
|
||||
}
|
||||
const open = [];
|
||||
if (subLink.value) open.push('sub');
|
||||
if (subJsonLink.value) open.push('sub-json');
|
||||
activeKeys.value = open;
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const props = defineProps({
|
||||
value: { type: String, required: true },
|
||||
remark: { type: String, default: '' },
|
||||
downloadName: { type: String, default: '' },
|
||||
size: { type: Number, default: 240 },
|
||||
size: { type: Number, default: 360 },
|
||||
showQr: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ function download() {
|
||||
</div>
|
||||
<div v-if="showQr" class="qr-panel-canvas">
|
||||
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
|
||||
:title="t('copy')" @click="copy" />
|
||||
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -82,8 +82,15 @@ function download() {
|
||||
|
||||
.qr-panel-canvas .qr-code {
|
||||
cursor: pointer;
|
||||
padding: 0 !important;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.qr-panel-canvas .qr-code :deep(svg) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 360px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,6 +23,11 @@ export function useInbounds() {
|
||||
const clientCount = ref({});
|
||||
const onlineClients = 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.
|
||||
const expireDiff = ref(0);
|
||||
@@ -173,9 +178,9 @@ export function useInbounds() {
|
||||
rebuildClientCount();
|
||||
}
|
||||
|
||||
// The client_stats payload carries absolute traffic counters for the
|
||||
// clients that had activity in the latest window plus per-inbound
|
||||
// totals. Both are absolute (not deltas), so we overwrite in place.
|
||||
// The client_stats payload carries absolute traffic counters for every
|
||||
// client + per-inbound totals (full snapshot, not deltas). Both are
|
||||
// overwritten in place.
|
||||
function applyClientStatsEvent(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
let touched = false;
|
||||
@@ -220,6 +225,7 @@ export function useInbounds() {
|
||||
}
|
||||
|
||||
if (touched) {
|
||||
statsVersion.value++;
|
||||
dbInbounds.value = [...dbInbounds.value];
|
||||
rebuildClientCount();
|
||||
}
|
||||
@@ -315,6 +321,7 @@ export function useInbounds() {
|
||||
clientCount,
|
||||
onlineClients,
|
||||
lastOnlineMap,
|
||||
statsVersion,
|
||||
totals,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
SwapOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
ThunderboltOutlined,
|
||||
DesktopOutlined,
|
||||
DatabaseOutlined,
|
||||
ForkOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -31,6 +35,7 @@ import PanelUpdateModal from './PanelUpdateModal.vue';
|
||||
import LogModal from './LogModal.vue';
|
||||
import BackupModal from './BackupModal.vue';
|
||||
import SystemHistoryModal from './SystemHistoryModal.vue';
|
||||
import XrayMetricsModal from './XrayMetricsModal.vue';
|
||||
import XrayLogModal from './XrayLogModal.vue';
|
||||
import VersionModal from './VersionModal.vue';
|
||||
|
||||
@@ -71,6 +76,7 @@ const logsOpen = ref(false);
|
||||
const backupOpen = ref(false);
|
||||
const panelUpdateOpen = ref(false);
|
||||
const sysHistoryOpen = ref(false);
|
||||
const xrayMetricsOpen = ref(false);
|
||||
const xrayLogsOpen = ref(false);
|
||||
const versionOpen = ref(false);
|
||||
const configTextOpen = ref(false);
|
||||
@@ -98,6 +104,18 @@ function openSystemHistory() { sysHistoryOpen.value = true; }
|
||||
function openXrayLogs() { xrayLogsOpen.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
|
||||
// it as JSON in the shared TextModal (same UX as main).
|
||||
async function openConfig() {
|
||||
@@ -155,62 +173,83 @@ async function openConfig() {
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="3X-UI" hoverable>
|
||||
<template v-if="panelUpdateInfo.updateAvailable" #extra>
|
||||
<a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`">
|
||||
<a-tag color="orange" class="update-tag" @click="panelUpdateOpen = true">
|
||||
<CloudDownloadOutlined />
|
||||
{{ panelUpdateInfo.latestVersion }}
|
||||
<span v-if="!isMobile">{{ t('pages.index.updatePanel') }}</span>
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
<template #actions>
|
||||
<a-space class="action" @click="openTelegram">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" class="tg-icon"
|
||||
aria-hidden="true">
|
||||
<path
|
||||
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" />
|
||||
</svg>
|
||||
<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>
|
||||
<div class="link-tags">
|
||||
<a href="https://github.com/MHSanaei/3x-ui/releases" target="_blank" rel="noopener noreferrer">
|
||||
<a-tag color="green">v{{ displayVersion }}</a-tag>
|
||||
</a>
|
||||
<a href="https://t.me/XrayUI" target="_blank" rel="noopener noreferrer">
|
||||
<a-tag color="green">@XrayUI</a-tag>
|
||||
</a>
|
||||
<a href="https://github.com/MHSanaei/3x-ui/wiki" target="_blank" rel="noopener noreferrer">
|
||||
<a-tag color="purple">{{ t('pages.index.documentation') }}</a-tag>
|
||||
</a>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('pages.index.operationHours')" hoverable>
|
||||
<a-tag :color="status.xray.color">
|
||||
Xray: {{ TimeFormatter.formatSecond(status.appStats.uptime) }}
|
||||
</a-tag>
|
||||
<a-tag color="green">OS: {{ TimeFormatter.formatSecond(status.uptime) }}</a-tag>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('pages.index.systemLoad')" hoverable>
|
||||
<template #extra>
|
||||
<a-tag color="blue" class="history-tag" @click="openSystemHistory">
|
||||
<AreaChartOutlined />
|
||||
{{ t('pages.index.systemHistoryTitle') }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<a-tooltip :title="t('pages.index.systemLoadDesc')">
|
||||
<a-tag color="green">
|
||||
{{ status.loads[0] }} | {{ status.loads[1] }} | {{ status.loads[2] }}
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||
<a-col :span="12">
|
||||
<CustomStatistic title="Xray" :value="TimeFormatter.formatSecond(status.appStats.uptime)">
|
||||
<template #prefix>
|
||||
<ThunderboltOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<CustomStatistic title="OS" :value="TimeFormatter.formatSecond(status.uptime)">
|
||||
<template #prefix>
|
||||
<DesktopOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('usage')" hoverable>
|
||||
<a-tag color="green">
|
||||
{{ t('pages.index.memory') }}: {{ SizeFormatter.sizeFormat(status.appStats.mem) }}
|
||||
</a-tag>
|
||||
<a-tag color="green">
|
||||
{{ t('pages.index.threads') }}: {{ status.appStats.threads }}
|
||||
</a-tag>
|
||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||
<a-col :span="12">
|
||||
<CustomStatistic :title="t('pages.index.memory')"
|
||||
:value="SizeFormatter.sizeFormat(status.appStats.mem)">
|
||||
<template #prefix>
|
||||
<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-col>
|
||||
|
||||
@@ -318,6 +357,7 @@ async function openConfig() {
|
||||
<LogModal v-model:open="logsOpen" />
|
||||
<BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
|
||||
<SystemHistoryModal v-model:open="sysHistoryOpen" :status="status" />
|
||||
<XrayMetricsModal v-model:open="xrayMetricsOpen" />
|
||||
<XrayLogModal v-model:open="xrayLogsOpen" />
|
||||
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
|
||||
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
|
||||
@@ -374,12 +414,13 @@ async function openConfig() {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.update-tag {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
.action-update {
|
||||
color: #fa8c16;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-update :deep(.anticon) {
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
.history-tag {
|
||||
@@ -390,18 +431,9 @@ async function openConfig() {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.link-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.link-tags a {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.link-tags :deep(.ant-tag) {
|
||||
margin-inline-end: 0;
|
||||
.tg-icon {
|
||||
display: inline-block;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
.ip-toggle-icon {
|
||||
|
||||
@@ -53,7 +53,9 @@ function parseLogLine(line) {
|
||||
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));
|
||||
@@ -133,33 +135,25 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
|
||||
<template v-else-if="isMobile">
|
||||
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-card">
|
||||
<div class="log-card-head">
|
||||
<span v-if="log.date || log.time" class="log-time">
|
||||
<span v-if="log.time">{{ log.time }}</span>
|
||||
<span v-if="log.date" class="log-date">{{ log.date }}</span>
|
||||
<span v-if="log.stamp" class="log-time">
|
||||
<span v-if="log.time">{{ log.time }}</span>{{ log.time && log.date ? ' ' : '' }}<span v-if="log.date" class="log-date">{{ log.date }}</span>
|
||||
</span>
|
||||
<span v-if="log.levelText" class="log-level-badge" :class="log.levelClass">
|
||||
{{ log.levelText }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="log.body || log.service" class="log-body">
|
||||
<b v-if="log.service">{{ log.service }}</b>
|
||||
<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
|
||||
<b v-if="log.service">{{ log.service }}</b>{{ log.service && log.body ? ' ' : '' }}<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-line">
|
||||
<span v-if="log.date || log.time" class="log-stamp">
|
||||
{{ log.date }}<template v-if="log.date && log.time"> </template>{{ log.time }}
|
||||
</span>
|
||||
<span v-if="log.levelText" class="log-level" :class="log.levelClass">
|
||||
{{ log.levelText }}
|
||||
</span>
|
||||
<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>
|
||||
<template v-if="log.body || log.service">
|
||||
<span> - </span>
|
||||
<b v-if="log.service">{{ log.service }} </b>
|
||||
<span>{{ log.body }}</span>
|
||||
<b v-if="log.service">{{ log.service }}</b>{{ log.service && log.body ? ' ' : '' }}<span>{{ log.body }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
347
frontend/src/pages/index/XrayMetricsModal.vue
Normal file
347
frontend/src/pages/index/XrayMetricsModal.vue
Normal file
@@ -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>
|
||||
@@ -28,6 +28,7 @@ function defaultForm() {
|
||||
basePath: '/',
|
||||
apiToken: '',
|
||||
enable: true,
|
||||
allowPrivateAddress: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +70,7 @@ function buildPayload() {
|
||||
basePath: form.basePath?.trim() || '/',
|
||||
apiToken: form.apiToken?.trim() || '',
|
||||
enable: !!form.enable,
|
||||
allowPrivateAddress: !!form.allowPrivateAddress,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,6 +163,11 @@ async function onSave() {
|
||||
</a-col>
|
||||
</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-input-password v-model:value="form.apiToken" :placeholder="t('pages.nodes.apiTokenPlaceholder')" />
|
||||
<div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
EditOutlined,
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
PlusOutlined,
|
||||
ThunderboltOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
InfoCircleOutlined,
|
||||
MoreOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import NodeHistoryPanel from './NodeHistoryPanel.vue';
|
||||
|
||||
@@ -26,8 +31,6 @@ const emit = defineEmits([
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Render the address column as a clickable URL so admins can jump to
|
||||
// the remote panel directly from the list.
|
||||
const dataSource = computed(() =>
|
||||
props.nodes.map((n) => ({
|
||||
...n,
|
||||
@@ -36,6 +39,8 @@ const dataSource = computed(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const showAddress = ref(false);
|
||||
|
||||
function statusColor(status) {
|
||||
switch (status) {
|
||||
case 'online': return 'green';
|
||||
@@ -70,6 +75,25 @@ function formatPct(p) {
|
||||
if (typeof p !== 'number' || isNaN(p)) return '-';
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -83,7 +107,103 @@ function formatPct(p) {
|
||||
</a-button>
|
||||
</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">
|
||||
<template #expandedRowRender="{ record }">
|
||||
<NodeHistoryPanel :node="record" />
|
||||
@@ -97,9 +217,19 @@ function formatPct(p) {
|
||||
</template>
|
||||
</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 }">
|
||||
<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>
|
||||
</a-table-column>
|
||||
|
||||
@@ -203,4 +333,133 @@ function formatPct(p) {
|
||||
font-size: 12px;
|
||||
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>
|
||||
|
||||
@@ -108,33 +108,33 @@ async function onToggleEnable(node, next) {
|
||||
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
||||
<div v-if="!fetched" class="loading-spacer" />
|
||||
|
||||
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
|
||||
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
|
||||
<!-- Summary statistics card -->
|
||||
<a-col :span="24">
|
||||
<a-card size="small" hoverable class="summary-card">
|
||||
<a-row :gutter="[16, 12]">
|
||||
<a-col :sm="12" :md="6">
|
||||
<a-row :gutter="[16, isMobile ? 16 : 12]">
|
||||
<a-col :xs="12" :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)">
|
||||
<template #prefix>
|
||||
<CloudServerOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :sm="12" :md="6">
|
||||
<a-col :xs="12" :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)">
|
||||
<template #prefix>
|
||||
<CheckCircleOutlined style="color: #52c41a" />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :sm="12" :md="6">
|
||||
<a-col :xs="12" :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)">
|
||||
<template #prefix>
|
||||
<CloseCircleOutlined style="color: #ff4d4f" />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :sm="12" :md="6">
|
||||
<a-col :xs="12" :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.avgLatency')"
|
||||
:value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'">
|
||||
<template #prefix>
|
||||
|
||||
@@ -153,6 +153,14 @@ onMounted(loadInboundTags);
|
||||
</template>
|
||||
</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">
|
||||
<template #title>{{ t('pages.settings.pageSize') }}</template>
|
||||
<template #description>{{ t('pages.settings.pageSizeDesc') }}</template>
|
||||
@@ -298,8 +306,12 @@ onMounted(loadInboundTags);
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('password') }}</template>
|
||||
<template #description>
|
||||
{{ allSetting.hasLdapPassword ? 'Configured; leave blank to keep current password.' : 'Not configured.' }}
|
||||
</template>
|
||||
<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>
|
||||
</SettingListItem>
|
||||
|
||||
|
||||
@@ -52,10 +52,9 @@ async function sendUpdateUser() {
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/updateUser', user);
|
||||
if (msg?.success) {
|
||||
// Force re-login at the standard logout path; basePath is handled
|
||||
// by the Go router so a relative redirect is correct here.
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
window.location.replace(`${basePath}logout`);
|
||||
await HttpUtil.post('/logout');
|
||||
const basePath = window.X_UI_BASE_PATH || '/';
|
||||
window.location.replace(basePath);
|
||||
}
|
||||
} finally {
|
||||
updating.value = false;
|
||||
@@ -76,34 +75,41 @@ function updateUser() {
|
||||
}
|
||||
}
|
||||
|
||||
// === API Token =========================================================
|
||||
// Surfaces the panel's API token so a remote central panel can register
|
||||
// this instance as a node. Lazy-loaded on tab mount; rotation requires
|
||||
// confirmation since it invalidates any cached value upstream.
|
||||
const apiToken = ref('');
|
||||
const apiTokenLoading = ref(false);
|
||||
const apiTokenRotating = ref(false);
|
||||
const apiTokens = ref([]);
|
||||
const apiTokensLoading = ref(false);
|
||||
const visibleTokenIds = ref(new Set());
|
||||
const createOpen = ref(false);
|
||||
const createName = ref('');
|
||||
const creating = ref(false);
|
||||
|
||||
async function loadApiToken() {
|
||||
apiTokenLoading.value = true;
|
||||
async function loadApiTokens() {
|
||||
apiTokensLoading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/getApiToken');
|
||||
if (msg?.success) apiToken.value = msg.obj || '';
|
||||
const msg = await HttpUtil.get('/panel/setting/apiTokens');
|
||||
if (msg?.success) apiTokens.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
} finally {
|
||||
apiTokenLoading.value = false;
|
||||
apiTokensLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyApiToken() {
|
||||
if (!apiToken.value) return;
|
||||
function isTokenVisible(id) {
|
||||
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 {
|
||||
await navigator.clipboard.writeText(apiToken.value);
|
||||
await navigator.clipboard.writeText(token);
|
||||
message.success(t('copySuccess'));
|
||||
} catch (_e) {
|
||||
// navigator.clipboard can be undefined on http:// — fall back to
|
||||
// a transient input + execCommand path.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = apiToken.value;
|
||||
ta.value = token;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
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({
|
||||
title: t('pages.nodes.regenerateConfirm'),
|
||||
okText: t('confirm'),
|
||||
title: `${t('delete')} "${row.name}"?`,
|
||||
content: t('pages.settings.security.apiTokenDeleteWarning')
|
||||
|| 'Any caller using this token will stop authenticating immediately.',
|
||||
okText: t('delete'),
|
||||
cancelText: t('cancel'),
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
apiTokenRotating.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
|
||||
if (msg?.success) {
|
||||
apiToken.value = msg.obj || '';
|
||||
message.success(t('success'));
|
||||
}
|
||||
} finally {
|
||||
apiTokenRotating.value = false;
|
||||
}
|
||||
const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`);
|
||||
if (msg?.success) await loadApiTokens();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
// Switch read-only — the actual flip happens after the modal succeeds.
|
||||
@@ -217,24 +261,144 @@ function toggleTwoFactor() {
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="3" :header="t('pages.nodes.apiToken')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.nodes.apiToken') }}</template>
|
||||
<template #description>{{ t('pages.nodes.apiTokenHint') }}</template>
|
||||
<template #control>
|
||||
<a-input-password :value="apiToken" readonly :loading="apiTokenLoading" style="min-width: 240px" />
|
||||
</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') }}
|
||||
<div class="api-token-section">
|
||||
<div class="api-token-header">
|
||||
<p class="api-token-hint">{{ t('pages.nodes.apiTokenHint') }}</p>
|
||||
<a-button type="primary" size="small" @click="openCreateModal">
|
||||
+ {{ t('pages.settings.security.apiTokenNew') || 'New token' }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-list-item>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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"
|
||||
:type="tfa.type" @confirm="onTfaConfirm" />
|
||||
</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>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import {
|
||||
@@ -152,6 +152,35 @@ const confAlerts = computed(() => {
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -199,39 +228,49 @@ const alertVisible = ref(true);
|
||||
</a-col>
|
||||
|
||||
<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">
|
||||
<template #tab>
|
||||
<SettingOutlined />
|
||||
<span>{{ t('pages.settings.panelSettings') }}</span>
|
||||
<a-tooltip :title="isMobile ? t('pages.settings.panelSettings') : null">
|
||||
<SettingOutlined />
|
||||
</a-tooltip>
|
||||
<span v-if="!isMobile">{{ t('pages.settings.panelSettings') }}</span>
|
||||
</template>
|
||||
<GeneralTab :all-setting="allSetting" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="2" class="tab-pane">
|
||||
<template #tab>
|
||||
<SafetyOutlined />
|
||||
<span>{{ t('pages.settings.securitySettings') }}</span>
|
||||
<a-tooltip :title="isMobile ? t('pages.settings.securitySettings') : null">
|
||||
<SafetyOutlined />
|
||||
</a-tooltip>
|
||||
<span v-if="!isMobile">{{ t('pages.settings.securitySettings') }}</span>
|
||||
</template>
|
||||
<SecurityTab :all-setting="allSetting" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="3" class="tab-pane">
|
||||
<template #tab>
|
||||
<MessageOutlined />
|
||||
<span>{{ t('pages.settings.TGBotSettings') }}</span>
|
||||
<a-tooltip :title="isMobile ? t('pages.settings.TGBotSettings') : null">
|
||||
<MessageOutlined />
|
||||
</a-tooltip>
|
||||
<span v-if="!isMobile">{{ t('pages.settings.TGBotSettings') }}</span>
|
||||
</template>
|
||||
<TelegramTab :all-setting="allSetting" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="4" class="tab-pane">
|
||||
<template #tab>
|
||||
<CloudServerOutlined />
|
||||
<span>{{ t('pages.settings.subSettings') }}</span>
|
||||
<a-tooltip :title="isMobile ? t('pages.settings.subSettings') : null">
|
||||
<CloudServerOutlined />
|
||||
</a-tooltip>
|
||||
<span v-if="!isMobile">{{ t('pages.settings.subSettings') }}</span>
|
||||
</template>
|
||||
<SubscriptionGeneralTab :all-setting="allSetting" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane v-if="allSetting.subJsonEnable || allSetting.subClashEnable" key="5" class="tab-pane">
|
||||
<template #tab>
|
||||
<CodeOutlined />
|
||||
<span>{{ t('pages.settings.subSettings') }} (Formats)</span>
|
||||
<a-tooltip :title="isMobile ? `${t('pages.settings.subSettings')} (Formats)` : null">
|
||||
<CodeOutlined />
|
||||
</a-tooltip>
|
||||
<span v-if="!isMobile">{{ t('pages.settings.subSettings') }} (Formats)</span>
|
||||
</template>
|
||||
<SubscriptionFormatsTab :all-setting="allSetting" />
|
||||
</a-tab-pane>
|
||||
@@ -304,4 +343,33 @@ const alertVisible = ref(true);
|
||||
.tab-pane {
|
||||
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>
|
||||
|
||||
@@ -112,6 +112,14 @@ function normalizeSubPath() {
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
|
||||
@@ -23,9 +23,12 @@ defineProps({
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<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>
|
||||
<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>
|
||||
</SettingListItem>
|
||||
|
||||
|
||||
@@ -38,18 +38,24 @@ function buildTotp() {
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
enteredCode.value = '';
|
||||
totp = null;
|
||||
qrValue.value = '';
|
||||
if (props.token) {
|
||||
buildTotp();
|
||||
}
|
||||
});
|
||||
|
||||
function close(success) {
|
||||
emit('confirm', success);
|
||||
function close(success, code = '') {
|
||||
emit('confirm', success, code);
|
||||
emit('update:open', false);
|
||||
enteredCode.value = '';
|
||||
}
|
||||
|
||||
function onOk() {
|
||||
if (props.type === 'confirm' && !props.token) {
|
||||
close(true, enteredCode.value);
|
||||
return;
|
||||
}
|
||||
if (!totp) return;
|
||||
if (totp.generate() === enteredCode.value) {
|
||||
close(true);
|
||||
@@ -76,7 +82,7 @@ async function copyToken() {
|
||||
<p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
|
||||
<div class="qr-wrap">
|
||||
<a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
|
||||
error-level="L" :title="t('copy')" @click="copyToken" />
|
||||
color="#000000" bg-color="#ffffff" error-level="L" :title="t('copy')" @click="copyToken" />
|
||||
<span class="qr-token">{{ token }}</span>
|
||||
</div>
|
||||
<a-divider />
|
||||
|
||||
@@ -40,7 +40,6 @@ const subUrl = subData.subUrl || '';
|
||||
const subJsonUrl = subData.subJsonUrl || '';
|
||||
const subClashUrl = subData.subClashUrl || '';
|
||||
const subTitle = subData.subTitle || '';
|
||||
const subSupportUrl = subData.subSupportUrl || '';
|
||||
const links = Array.isArray(subData.links) ? subData.links : [];
|
||||
// Panel's "Calendar Type" setting; controls whether expiry / lastOnline
|
||||
// render in Gregorian or Jalali on this standalone subscription page.
|
||||
@@ -205,7 +204,7 @@ const themeClass = computed(() => ({
|
||||
<div class="qr-box">
|
||||
<a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
|
||||
<a-qrcode class="qr-code" :value="subUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||
:title="t('copy')" @click="copy(subUrl)" />
|
||||
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subUrl)" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
|
||||
@@ -214,14 +213,14 @@ const themeClass = computed(() => ({
|
||||
{{ t('pages.settings.subSettings') }} JSON
|
||||
</a-tag>
|
||||
<a-qrcode class="qr-code" :value="subJsonUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||
:title="t('copy')" @click="copy(subJsonUrl)" />
|
||||
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subJsonUrl)" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
|
||||
<div class="qr-box">
|
||||
<a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
|
||||
<a-qrcode class="qr-code" :value="subClashUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||
:title="t('copy')" @click="copy(subClashUrl)" />
|
||||
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subClashUrl)" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Modal } from 'ant-design-vue';
|
||||
|
||||
import BalancerFormModal from './BalancerFormModal.vue';
|
||||
import JsonEditor from '@/components/JsonEditor.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -21,6 +22,7 @@ const { t } = useI18n();
|
||||
const props = defineProps({
|
||||
templateSettings: { type: Object, default: null },
|
||||
clientReverseTags: { type: Array, default: () => [] },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const STRATEGY_LABELS = {
|
||||
@@ -196,7 +198,7 @@ function confirmDelete(idx) {
|
||||
}
|
||||
|
||||
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: 'Strategy', key: 'strategy', align: 'center', width: 140 },
|
||||
{ title: 'Selector', key: 'selector', align: 'center' },
|
||||
@@ -266,25 +268,39 @@ const obsText = computed({
|
||||
{{ t('pages.xray.Balancers') }}
|
||||
</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 v-if="column.key === 'action'">
|
||||
<span class="row-index">{{ index + 1 }}</span>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button shape="circle" size="small" class="action-btn">
|
||||
<MoreOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @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 class="action-cell">
|
||||
<span class="row-index">{{ index + 1 }}</span>
|
||||
|
||||
<div :class="!isMobile ? 'action-buttons' : ''">
|
||||
<a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button shape="circle" size="small">
|
||||
<template #icon>
|
||||
<MoreOutlined />
|
||||
</template>
|
||||
</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 v-else-if="column.key === 'strategy'">
|
||||
@@ -305,8 +321,7 @@ const obsText = computed({
|
||||
<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>
|
||||
<a-textarea v-model:value="obsText" :auto-size="{ minRows: 8, maxRows: 24 }" spellcheck="false"
|
||||
class="json-editor" />
|
||||
<JsonEditor v-model:value="obsText" min-height="220px" max-height="480px" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -316,23 +331,29 @@ const obsText = computed({
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row-index {
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
margin-right: 6px;
|
||||
min-width: 18px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
vertical-align: middle;
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.json-editor {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DNSRuleActions,
|
||||
} from '@/models/outbound.js';
|
||||
import FinalMaskForm from '@/components/FinalMaskForm.vue';
|
||||
import JsonEditor from '@/components/JsonEditor.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -80,8 +81,17 @@ watch(() => props.open, (next) => {
|
||||
primeAdvancedJson();
|
||||
});
|
||||
|
||||
watch(activeKey, (key) => {
|
||||
if (key === '2') primeAdvancedJson();
|
||||
let isRevertingTab = false;
|
||||
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() {
|
||||
@@ -93,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 onProtocolChange(next) {
|
||||
@@ -131,27 +168,15 @@ const tagHelp = computed(() => {
|
||||
// ============== Submit ==============
|
||||
function onOk() {
|
||||
if (!outbound.value) return;
|
||||
if (activeKey.value === '2' && !applyAdvancedJsonToForm()) return;
|
||||
if (!outbound.value.tag?.trim()) {
|
||||
message.error(t('somethingWentWrong'));
|
||||
message.error('Tag is required');
|
||||
return;
|
||||
}
|
||||
if (duplicateTag.value) {
|
||||
message.error(t('somethingWentWrong'));
|
||||
message.error('Tag already used by another outbound');
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -170,6 +195,7 @@ function convertLink() {
|
||||
return;
|
||||
}
|
||||
outbound.value = next;
|
||||
primeAdvancedJson();
|
||||
linkInput.value = '';
|
||||
message.success('Link imported successfully...');
|
||||
activeKey.value = '1';
|
||||
@@ -964,8 +990,7 @@ function regenerateWgKeys() {
|
||||
<a-button>Convert</a-button>
|
||||
</template>
|
||||
</a-input-search>
|
||||
<a-textarea v-model:value="advancedJson" :auto-size="{ minRows: 14, maxRows: 30 }" spellcheck="false"
|
||||
class="json-editor" />
|
||||
<JsonEditor v-model:value="advancedJson" min-height="360px" max-height="600px" />
|
||||
</a-space>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
@@ -1008,11 +1033,6 @@ function regenerateWgKeys() {
|
||||
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
|
||||
* inline-block, but inside a narrow form wrapper they can wrap
|
||||
* inconsistently. Force a clean horizontal row with even gaps. */
|
||||
|
||||
@@ -157,9 +157,9 @@ function hasBreakdown(r) {
|
||||
// === Columns ========================================================
|
||||
// Computed so titles re-render after a locale swap.
|
||||
const columns = computed(() => [
|
||||
{ title: '#', key: 'action', align: 'center', width: 70 },
|
||||
{ title: 'Tag', key: 'identity', align: 'left', width: 220 },
|
||||
{ title: t('pages.inbounds.address'), key: 'address', align: 'left', width: 230 },
|
||||
{ title: '#', key: 'action', align: 'center', width: 100 },
|
||||
{ title: 'Tag', key: 'identity', align: 'left' },
|
||||
{ title: t('pages.inbounds.address'), key: 'address', align: 'left' },
|
||||
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
|
||||
{ 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 },
|
||||
@@ -322,33 +322,41 @@ const rows = computed(() => {
|
||||
<template v-if="column.key === 'action'">
|
||||
<div class="action-cell">
|
||||
<span class="row-index">{{ index + 1 }}</span>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button shape="circle" size="small">
|
||||
<MoreOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item v-if="index > 0" @click="setFirst(index)">
|
||||
<VerticalAlignTopOutlined /> Move to top
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="openEdit(index)">
|
||||
<EditOutlined /> 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 @click="emit('reset-traffic', record.tag || '')">
|
||||
<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 class="action-buttons">
|
||||
<a-button shape="circle" size="small" @click="openEdit(index)">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button shape="circle" size="small">
|
||||
<template #icon>
|
||||
<MoreOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item v-if="index > 0" @click="setFirst(index)">
|
||||
<VerticalAlignTopOutlined /> Move to top
|
||||
</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 @click="emit('reset-traffic', record.tag || '')">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -444,6 +452,11 @@ const rows = computed(() => {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.toolbar-right :global(.ant-space),
|
||||
.header-actions :global(.ant-space) {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.card-empty {
|
||||
text-align: center;
|
||||
opacity: 0.4;
|
||||
@@ -526,6 +539,14 @@ const rows = computed(() => {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.identity-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ClusterOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
HolderOutlined,
|
||||
} from '@ant-design/icons-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
|
||||
// layout); full lists surface via tooltip on hover.
|
||||
//
|
||||
// Reorder uses up/down buttons in the action menu rather than the
|
||||
// jQuery-Sortable drag handle the legacy panel used — same effect,
|
||||
// no extra dep. The mobile column layout drops source/network/
|
||||
// Reorder via Pointer Events on the grip icon — unified mouse +
|
||||
// touch + pen path so the same code works on desktop and mobile
|
||||
// (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.
|
||||
|
||||
const props = defineProps({
|
||||
@@ -162,22 +165,91 @@ function moveDown(idx) {
|
||||
[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 =========================================================
|
||||
// Computed so titles re-render after a locale swap.
|
||||
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: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
|
||||
{ title: 'Destination', align: 'left', key: 'destination' },
|
||||
{ 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(() => [
|
||||
{ title: '#', align: 'center', width: 70, key: 'action' },
|
||||
{ title: t('pages.xray.Inbounds'), align: 'left', key: 'inbound' },
|
||||
{ title: t('pages.xray.Outbounds'), align: 'left', width: 140, key: 'target' },
|
||||
]);
|
||||
const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
|
||||
const columns = computed(() => desktopColumns.value);
|
||||
|
||||
function ruleCriteriaChips(rule) {
|
||||
const chips = [];
|
||||
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -189,34 +261,117 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
{{ t('pages.xray.Routings') }}
|
||||
</a-button>
|
||||
|
||||
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
|
||||
:scroll="isMobile ? {} : { x: 1000 }" size="small" class="routing-table">
|
||||
<!-- Mobile: stacked cards. The desktop a-table doesn't fit on a
|
||||
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 }">
|
||||
<!-- ============== # / actions ============== -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<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>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button shape="circle" size="small">
|
||||
<MoreOutlined />
|
||||
|
||||
<div :class="!isMobile ? 'action-buttons' : ''">
|
||||
<a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button shape="circle" size="small">
|
||||
<template #icon>
|
||||
<MoreOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item v-if="isMobile" @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>
|
||||
</template>
|
||||
|
||||
@@ -228,7 +383,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
<span class="criterion-label">IP</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>
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
|
||||
@@ -259,7 +414,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
<span class="criterion-label">L4</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>
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
|
||||
@@ -267,7 +422,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
<span class="criterion-label">Protocol</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>
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
|
||||
@@ -295,7 +450,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
<span class="criterion-label">Domain</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>
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
|
||||
@@ -303,7 +458,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
<span class="criterion-label">Port</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>
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty">—</span>
|
||||
@@ -326,25 +481,32 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
<span class="criterion-label">User</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>
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<span v-if="!record.inboundTag && !record.user" class="criterion-empty">—</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============== Outbound / balancer target ============== -->
|
||||
<template v-else-if="column.key === 'target'">
|
||||
<!-- ============== Outbound ============== -->
|
||||
<template v-else-if="column.key === 'outbound'">
|
||||
<div class="target-cell">
|
||||
<div v-if="record.outboundTag" class="target-row">
|
||||
<ExportOutlined class="target-icon" />
|
||||
<a-tag color="green">{{ record.outboundTag }}</a-tag>
|
||||
</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">
|
||||
<ClusterOutlined class="target-icon" />
|
||||
<a-tag color="purple">{{ record.balancerTag }}</a-tag>
|
||||
</div>
|
||||
<span v-if="!record.outboundTag && !record.balancerTag" class="criterion-empty">—</span>
|
||||
<span v-else class="criterion-empty">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -362,6 +524,36 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
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 {
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
@@ -369,6 +561,14 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.criterion-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -429,4 +629,136 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
.danger {
|
||||
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>
|
||||
|
||||
@@ -27,6 +27,7 @@ const loading = ref(false);
|
||||
const warpData = ref(null);
|
||||
const warpConfig = ref(null);
|
||||
const warpPlus = ref('');
|
||||
const licenseError = ref('');
|
||||
// Held in memory so the parent's add/reset handlers receive the same
|
||||
// object the modal computed from getConfig().
|
||||
const stagedOutbound = ref(null);
|
||||
@@ -41,6 +42,7 @@ watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
warpConfig.value = null;
|
||||
stagedOutbound.value = null;
|
||||
licenseError.value = '';
|
||||
fetchData();
|
||||
});
|
||||
|
||||
@@ -89,12 +91,15 @@ async function getConfig() {
|
||||
async function updateLicense() {
|
||||
if (warpPlus.value.length < 26) return;
|
||||
loading.value = true;
|
||||
licenseError.value = '';
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/warp/license', { license: warpPlus.value });
|
||||
if (msg?.success) {
|
||||
warpData.value = JSON.parse(msg.obj);
|
||||
warpConfig.value = null;
|
||||
warpPlus.value = '';
|
||||
} else {
|
||||
licenseError.value = msg?.msg || 'Failed to set WARP license.';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -233,9 +238,12 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
|
||||
<a-collapse-panel header="WARP / WARP+ license key">
|
||||
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 14 } }">
|
||||
<a-form-item label="Key">
|
||||
<a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" />
|
||||
<a-button type="primary" class="mt-8" :disabled="warpPlus.length < 26" :loading="loading"
|
||||
@click="updateLicense">Update</a-button>
|
||||
<a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" @update:value="licenseError = ''" />
|
||||
<div class="license-actions mt-8">
|
||||
<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>
|
||||
</a-collapse-panel>
|
||||
@@ -358,4 +366,16 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.license-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.license-error {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import BalancersTab from './BalancersTab.vue';
|
||||
import DnsTab from './DnsTab.vue';
|
||||
import WarpModal from './WarpModal.vue';
|
||||
import NordModal from './NordModal.vue';
|
||||
import JsonEditor from '@/components/JsonEditor.vue';
|
||||
import { useXraySetting } from './useXraySetting.js';
|
||||
import { useWebSocket } from '@/composables/useWebSocket.js';
|
||||
|
||||
@@ -186,9 +187,6 @@ 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 basePath = window.X_UI_BASE_PATH || '';
|
||||
@@ -208,6 +206,51 @@ function confirmRestart() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -234,7 +277,7 @@ function confirmRestart() {
|
||||
<a-row class="header-row">
|
||||
<a-col :xs="24" :sm="14" class="header-actions">
|
||||
<a-space direction="horizontal">
|
||||
<a-button type="primary" :disabled="saveDisabled" @click="saveAll">
|
||||
<a-button type="primary" :disabled="saveDisabled" @click="onSaveAll">
|
||||
{{ t('pages.xray.save') }}
|
||||
</a-button>
|
||||
<a-button type="primary" danger :disabled="!saveDisabled" @click="confirmRestart">
|
||||
@@ -259,10 +302,13 @@ function confirmRestart() {
|
||||
|
||||
<!-- Tabs -->
|
||||
<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">
|
||||
<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>
|
||||
<BasicsTab :template-settings="templateSettings" :outbound-test-url="outboundTestUrl"
|
||||
:warp-exist="warpExist" :nord-exist="nordExist"
|
||||
@@ -272,7 +318,10 @@ function confirmRestart() {
|
||||
|
||||
<a-tab-pane key="tpl-routing" class="tab-pane">
|
||||
<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>
|
||||
<RoutingTab :template-settings="templateSettings" :inbound-tags="inboundTags"
|
||||
:client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
|
||||
@@ -280,7 +329,10 @@ function confirmRestart() {
|
||||
|
||||
<a-tab-pane key="tpl-outbound" class="tab-pane">
|
||||
<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>
|
||||
<OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
|
||||
:outbound-test-states="outboundTestStates" :testing-all="testingAll"
|
||||
@@ -292,21 +344,31 @@ function confirmRestart() {
|
||||
|
||||
<a-tab-pane key="tpl-balancer" class="tab-pane">
|
||||
<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>
|
||||
<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 key="tpl-dns" class="tab-pane">
|
||||
<template #tab>
|
||||
<DatabaseOutlined /> <span>DNS</span>
|
||||
<a-tooltip :title="isMobile ? 'DNS' : null">
|
||||
<DatabaseOutlined />
|
||||
</a-tooltip>
|
||||
<span v-if="!isMobile">DNS</span>
|
||||
</template>
|
||||
<DnsTab :template-settings="templateSettings" />
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="tpl-advanced" class="tab-pane">
|
||||
<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>
|
||||
<a-list-item-meta :title="t('pages.xray.Template')" :description="t('pages.xray.TemplateDesc')" />
|
||||
<a-radio-group v-model:value="advSettings" button-style="solid"
|
||||
@@ -316,8 +378,7 @@ function confirmRestart() {
|
||||
<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-group>
|
||||
<a-textarea v-model:value="advancedText" :auto-size="{ minRows: 18, maxRows: 40 }"
|
||||
spellcheck="false" class="json-editor" />
|
||||
<JsonEditor v-model:value="advancedText" min-height="420px" max-height="720px" />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-col>
|
||||
@@ -404,8 +465,32 @@ function confirmRestart() {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.json-editor {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
.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>
|
||||
|
||||
@@ -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 {
|
||||
static async sleep(timeout) {
|
||||
await new Promise(resolve => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · Xray</title>
|
||||
<title>Xray Config</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
|
||||
27
go.mod
27
go.mod
@@ -22,10 +22,11 @@ require (
|
||||
github.com/xlzd/gotp v0.1.0
|
||||
github.com/xtls/xray-core v1.260327.0
|
||||
go.uber.org/atomic v1.11.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/sys v0.44.0
|
||||
golang.org/x/text v0.37.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/gorm v1.31.1
|
||||
)
|
||||
@@ -69,13 +70,13 @@ require (
|
||||
github.com/pires/go-proxyproto v0.12.0 // 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/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/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/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
@@ -86,16 +87,16 @@ require (
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/arch v0.26.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/arch v0.27.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sync v0.20.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/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
|
||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
|
||||
54
go.sum
54
go.sum
@@ -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/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/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
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/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/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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sagernet/sing v0.8.9 h1:iX8FyMrWNl/divVgTe7cLT9n36v6bfzfnCYlcM1cLaU=
|
||||
github.com/sagernet/sing v0.8.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
||||
github.com/sagernet/sing v0.8.10 h1:V5VZffy8rm4dtBVKIpKa8vibRR2SiJprtu/10DFUalU=
|
||||
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/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
|
||||
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
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.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
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/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
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=
|
||||
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=
|
||||
golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4=
|
||||
golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
|
||||
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
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/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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.2.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.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
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/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
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/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/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
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-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
|
||||
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/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
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/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -763,6 +763,9 @@ config_after_install() {
|
||||
|
||||
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
|
||||
echo ""
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
@@ -773,6 +776,7 @@ config_after_install() {
|
||||
echo -e "${green}Port: ${config_port}${plain}"
|
||||
echo -e "${green}WebBasePath: ${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 "${yellow}⚠ IMPORTANT: Save these credentials securely!${plain}"
|
||||
if [[ "$SSL_SCHEME" == "https" ]]; then
|
||||
|
||||
@@ -11,17 +11,25 @@ import (
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/config"
|
||||
"github.com/op/go-logging"
|
||||
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
const (
|
||||
maxLogBufferSize = 10240 // Maximum log entries kept in memory
|
||||
logFileName = "3xui.log" // Log file name
|
||||
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 (
|
||||
logger *logging.Logger
|
||||
logFile *os.File
|
||||
logger *logging.Logger
|
||||
fileRotate *lumberjack.Logger // nil when file backend disabled
|
||||
|
||||
// logBuffer maintains recent log entries in memory for web UI retrieval
|
||||
logBuffer []struct {
|
||||
@@ -81,8 +89,8 @@ func initDefaultBackend() logging.Backend {
|
||||
return logging.NewBackendFormatter(backend, newFormatter(includeTime))
|
||||
}
|
||||
|
||||
// initFileBackend creates the file logging backend.
|
||||
// Creates log directory and truncates log file on startup for fresh logs.
|
||||
// initFileBackend creates the file logging backend with size/age‑bounded rotation
|
||||
// so log volume cannot grow without limit on disk.
|
||||
func initFileBackend() logging.Backend {
|
||||
logDir := config.GetLogFolder()
|
||||
if err := os.MkdirAll(logDir, 0o750); err != nil {
|
||||
@@ -91,19 +99,16 @@ func initFileBackend() logging.Backend {
|
||||
}
|
||||
|
||||
logPath := filepath.Join(logDir, logFileName)
|
||||
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o660)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to open log file %s: %v\n", logPath, err)
|
||||
return nil
|
||||
fileRotate = &lumberjack.Logger{
|
||||
Filename: logPath,
|
||||
MaxSize: maxLogFileMB,
|
||||
MaxBackups: maxLogBackups,
|
||||
MaxAge: maxLogAgeDays,
|
||||
LocalTime: true,
|
||||
Compress: compressRotated,
|
||||
}
|
||||
|
||||
// Close previous log file if exists
|
||||
if logFile != nil {
|
||||
_ = logFile.Close()
|
||||
}
|
||||
logFile = file
|
||||
|
||||
backend := logging.NewLogBackend(file, "", 0)
|
||||
backend := logging.NewLogBackend(fileRotate, "", 0)
|
||||
return logging.NewBackendFormatter(backend, newFormatter(true))
|
||||
}
|
||||
|
||||
@@ -116,12 +121,12 @@ func newFormatter(withTime bool) logging.Formatter {
|
||||
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.
|
||||
func CloseLogger() {
|
||||
if logFile != nil {
|
||||
_ = logFile.Close()
|
||||
logFile = nil
|
||||
if fileRotate != nil {
|
||||
_ = fileRotate.Close()
|
||||
fileRotate = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
35
main.go
35
main.go
@@ -81,11 +81,7 @@ func runWebServer() {
|
||||
case syscall.SIGHUP:
|
||||
logger.Info("Received SIGHUP signal. Restarting servers...")
|
||||
|
||||
// --- FIX FOR TELEGRAM BOT CONFLICT (409): Stop bot before restart ---
|
||||
service.StopBot()
|
||||
// --
|
||||
|
||||
err := server.Stop()
|
||||
err := server.StopPanelOnly()
|
||||
if err != nil {
|
||||
logger.Debug("Error stopping web server:", err)
|
||||
}
|
||||
@@ -96,7 +92,7 @@ func runWebServer() {
|
||||
|
||||
server = web.NewServer()
|
||||
global.SetWebServer(server)
|
||||
err = server.Start()
|
||||
err = server.StartPanelOnly()
|
||||
if err != nil {
|
||||
log.Fatalf("Error restarting web server: %v", err)
|
||||
return
|
||||
@@ -395,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.
|
||||
func migrateDb() {
|
||||
inboundService := service.InboundService{}
|
||||
@@ -437,6 +455,7 @@ func main() {
|
||||
var reset bool
|
||||
var show bool
|
||||
var getCert bool
|
||||
var getApiToken bool
|
||||
var resetTwoFactor bool
|
||||
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
|
||||
settingCmd.BoolVar(&show, "show", false, "Display current settings")
|
||||
@@ -448,6 +467,7 @@ func main() {
|
||||
settingCmd.BoolVar(&resetTwoFactor, "resetTwoFactor", false, "Reset two-factor authentication settings")
|
||||
settingCmd.BoolVar(&getListen, "getListen", false, "Display current panel listenIP IP")
|
||||
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(&webKeyFile, "webCertKey", "", "Set path to private key file for panel")
|
||||
settingCmd.StringVar(&tgbottoken, "tgbottoken", "", "Set token for Telegram bot")
|
||||
@@ -505,6 +525,9 @@ func main() {
|
||||
if getCert {
|
||||
GetCertificate(getCert)
|
||||
}
|
||||
if getApiToken {
|
||||
GetApiToken(getApiToken)
|
||||
}
|
||||
if (tgbottoken != "") || (tgbotchatid != "") || (tgbotRuntime != "") {
|
||||
updateTgbotSetting(tgbottoken, tgbotchatid, tgbotRuntime)
|
||||
}
|
||||
|
||||
@@ -265,6 +265,14 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
|
||||
streamSettings["wsSettings"] = s.removeAcceptProxy(streamSettings["wsSettings"])
|
||||
case "httpupgrade":
|
||||
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
|
||||
}
|
||||
@@ -447,6 +455,9 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
|
||||
if udpIdleTimeout, ok := hyStream["udpIdleTimeout"].(float64); ok {
|
||||
outHyStream["udpIdleTimeout"] = int(udpIdleTimeout)
|
||||
}
|
||||
if masquerade, ok := hyStream["masquerade"].(map[string]any); ok {
|
||||
outHyStream["masquerade"] = masquerade
|
||||
}
|
||||
newStream["hysteriaSettings"] = outHyStream
|
||||
|
||||
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
|
||||
|
||||
@@ -28,6 +28,7 @@ type SubService struct {
|
||||
showInfo bool
|
||||
remarkModel string
|
||||
datepicker string
|
||||
emailInRemark bool
|
||||
inboundService service.InboundService
|
||||
settingService service.SettingService
|
||||
// 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 {
|
||||
s.datepicker = "gregorian"
|
||||
}
|
||||
|
||||
s.emailInRemark, err = s.settingService.GetSubEmailInRemark()
|
||||
if err != nil {
|
||||
s.emailInRemark = true
|
||||
}
|
||||
|
||||
seenEmails := make(map[string]struct{})
|
||||
for _, inbound := range inbounds {
|
||||
clients, err := s.inboundService.GetClients(inbound)
|
||||
@@ -886,7 +893,7 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
|
||||
'e': "",
|
||||
'o': "",
|
||||
}
|
||||
if len(email) > 0 {
|
||||
if len(email) > 0 && s.emailInRemark {
|
||||
orders['e'] = email
|
||||
}
|
||||
if len(inbound.Remark) > 0 {
|
||||
@@ -1018,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{
|
||||
"sessionPlacement", "sessionKey",
|
||||
"seqPlacement", "seqKey",
|
||||
|
||||
80
util/netsafe/netsafe.go
Normal file
80
util/netsafe/netsafe.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type APIController struct {
|
||||
nodeController *NodeController
|
||||
settingService service.SettingService
|
||||
userService service.UserService
|
||||
apiTokenService service.ApiTokenService
|
||||
Tgbot service.Tgbot
|
||||
}
|
||||
|
||||
@@ -29,25 +30,11 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *
|
||||
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) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
tok := strings.TrimPrefix(auth, "Bearer ")
|
||||
if a.settingService.MatchApiToken(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 a.apiTokenService.Match(tok) {
|
||||
if u, err := a.userService.GetFirstUser(); err == nil {
|
||||
session.SetAPIAuthUser(c, u)
|
||||
}
|
||||
@@ -57,7 +44,11 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
c.Next()
|
||||
@@ -85,7 +76,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
|
||||
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
|
||||
|
||||
// Extra routes
|
||||
api.GET("/backuptotgbot", a.BackuptoTgbot)
|
||||
api.POST("/backuptotgbot", a.BackuptoTgbot)
|
||||
}
|
||||
|
||||
// BackuptoTgbot sends a backup of the panel data to Telegram bot admins.
|
||||
|
||||
160
web/controller/api_docs_test.go
Normal file
160
web/controller/api_docs_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,11 @@ func serveDistPage(c *gin.Context, name string) {
|
||||
}
|
||||
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
|
||||
|
||||
script := `<script>window.X_UI_BASE_PATH="` + escapedBase + `"`
|
||||
nonceAttr := ""
|
||||
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 + `"`
|
||||
|
||||
@@ -77,6 +77,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/:id/copyClients", a.copyInboundClients)
|
||||
g.POST("/:id/delClient/:clientId", a.delInboundClient)
|
||||
g.POST("/updateClient/:clientId", a.updateInboundClient)
|
||||
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
|
||||
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
|
||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||
g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
|
||||
@@ -441,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.
|
||||
func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
||||
err := a.inboundService.ResetAllTraffics()
|
||||
@@ -582,17 +601,19 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
|
||||
// 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 h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" {
|
||||
if i := strings.Index(h, ","); i >= 0 {
|
||||
h = strings.TrimSpace(h[:i])
|
||||
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 hp, _, err := net.SplitHostPort(h); err == nil {
|
||||
return hp
|
||||
if h := c.GetHeader("X-Real-IP"); h != "" {
|
||||
return h
|
||||
}
|
||||
return h
|
||||
}
|
||||
if h := c.GetHeader("X-Real-IP"); h != "" {
|
||||
return h
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(c.Request.Host); err == nil {
|
||||
return h
|
||||
|
||||
@@ -39,15 +39,10 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
|
||||
// initRouter sets up the routes for index, login, logout, and two-factor authentication.
|
||||
func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/", a.index)
|
||||
g.GET("/logout", a.logout)
|
||||
// Public CSRF endpoint — the SPA login page (served by Vite in
|
||||
// dev or by serveDistPage in prod) needs a token to POST /login,
|
||||
// but the panel-side /panel/csrf-token sits behind checkLogin.
|
||||
// EnsureCSRFToken creates a session token even for anonymous
|
||||
// callers, so any pre-login flow can bootstrap from here.
|
||||
g.GET("/csrf-token", a.csrfToken)
|
||||
|
||||
g.POST("/login", middleware.CSRFMiddleware(), a.login)
|
||||
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
|
||||
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
|
||||
}
|
||||
|
||||
@@ -140,7 +135,6 @@ func loginFailureReason(err error) string {
|
||||
return "invalid credentials"
|
||||
}
|
||||
|
||||
// logout handles user logout by clearing the session and redirecting to the login page.
|
||||
func (a *IndexController) logout(c *gin.Context) {
|
||||
user := session.GetLoginUser(c)
|
||||
if user != nil {
|
||||
@@ -150,7 +144,7 @@ func (a *IndexController) logout(c *gin.Context) {
|
||||
logger.Warning("Unable to clear session on logout:", err)
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// csrfToken returns the session CSRF token. Public — the login page
|
||||
|
||||
@@ -23,9 +23,10 @@ var filenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
|
||||
type ServerController struct {
|
||||
BaseController
|
||||
|
||||
serverService service.ServerService
|
||||
settingService service.SettingService
|
||||
panelService service.PanelService
|
||||
serverService service.ServerService
|
||||
settingService service.SettingService
|
||||
panelService service.PanelService
|
||||
xrayMetricsService service.XrayMetricsService
|
||||
|
||||
lastStatus *service.Status
|
||||
|
||||
@@ -47,6 +48,10 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/status", a.status)
|
||||
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
|
||||
g.GET("/history/:metric/:bucket", a.getMetricHistoryBucket)
|
||||
g.GET("/xrayMetricsState", a.getXrayMetricsState)
|
||||
g.GET("/xrayMetricsHistory/:metric/:bucket", a.getXrayMetricsHistoryBucket)
|
||||
g.GET("/xrayObservatory", a.getXrayObservatory)
|
||||
g.GET("/xrayObservatoryHistory/:tag/:bucket", a.getXrayObservatoryHistoryBucket)
|
||||
g.GET("/getXrayVersion", a.getXrayVersion)
|
||||
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
||||
g.GET("/getConfigJson", a.getConfigJson)
|
||||
@@ -75,7 +80,9 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
func (a *ServerController) refreshStatus() {
|
||||
a.lastStatus = a.serverService.GetStatus(a.lastStatus)
|
||||
if a.lastStatus != nil {
|
||||
a.serverService.AppendStatusSample(time.Now(), a.lastStatus)
|
||||
now := time.Now()
|
||||
a.serverService.AppendStatusSample(now, a.lastStatus)
|
||||
a.xrayMetricsService.Sample(now)
|
||||
// Broadcast status update via WebSocket
|
||||
websocket.BroadcastStatus(a.lastStatus)
|
||||
}
|
||||
@@ -143,6 +150,42 @@ func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
|
||||
jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getXrayMetricsState(c *gin.Context) {
|
||||
jsonObj(c, a.xrayMetricsService.State(), nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getXrayMetricsHistoryBucket(c *gin.Context) {
|
||||
metric := c.Param("metric")
|
||||
if !slices.Contains(service.XrayMetricKeys, metric) {
|
||||
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
||||
return
|
||||
}
|
||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
||||
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
|
||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||
return
|
||||
}
|
||||
jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getXrayObservatory(c *gin.Context) {
|
||||
jsonObj(c, a.xrayMetricsService.ObservatorySnapshot(), nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getXrayObservatoryHistoryBucket(c *gin.Context) {
|
||||
tag := c.Param("tag")
|
||||
if !a.xrayMetricsService.HasObservatoryTag(tag) {
|
||||
jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
|
||||
return
|
||||
}
|
||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
||||
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
|
||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||
return
|
||||
}
|
||||
jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getXrayVersion(c *gin.Context) {
|
||||
const cacheTTLSeconds = 15 * 60
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
||||
@@ -22,9 +23,10 @@ type updateUserForm struct {
|
||||
|
||||
// SettingController handles settings and user management operations.
|
||||
type SettingController struct {
|
||||
settingService service.SettingService
|
||||
userService service.UserService
|
||||
panelService service.PanelService
|
||||
settingService service.SettingService
|
||||
userService service.UserService
|
||||
panelService service.PanelService
|
||||
apiTokenService service.ApiTokenService
|
||||
}
|
||||
|
||||
// NewSettingController creates a new SettingController and initializes its routes.
|
||||
@@ -44,8 +46,10 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/updateUser", a.updateUser)
|
||||
g.POST("/restartPanel", a.restartPanel)
|
||||
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
|
||||
g.GET("/getApiToken", a.getApiToken)
|
||||
g.POST("/regenerateApiToken", a.regenerateApiToken)
|
||||
g.GET("/apiTokens", a.listApiTokens)
|
||||
g.POST("/apiTokens/create", a.createApiToken)
|
||||
g.POST("/apiTokens/delete/:id", a.deleteApiToken)
|
||||
g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
|
||||
}
|
||||
|
||||
// getAllSetting retrieves all current settings.
|
||||
@@ -76,7 +80,13 @@ func (a *SettingController) updateSetting(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
|
||||
err = a.settingService.UpdateAllSetting(allSetting)
|
||||
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
|
||||
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
|
||||
err = bumpErr
|
||||
}
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
}
|
||||
|
||||
@@ -124,26 +134,56 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
|
||||
jsonObj(c, defaultJsonConfig, nil)
|
||||
}
|
||||
|
||||
// getApiToken returns the panel's API token used by remote central
|
||||
// panels to authenticate as Bearer tokens. The token is auto-generated
|
||||
// on first read so existing installs upgrade transparently.
|
||||
func (a *SettingController) getApiToken(c *gin.Context) {
|
||||
tok, err := a.settingService.GetApiToken()
|
||||
type apiTokenCreateForm struct {
|
||||
Name string `json:"name" form:"name"`
|
||||
}
|
||||
|
||||
type apiTokenEnabledForm struct {
|
||||
Enabled bool `json:"enabled" form:"enabled"`
|
||||
}
|
||||
|
||||
func (a *SettingController) listApiTokens(c *gin.Context) {
|
||||
rows, err := a.apiTokenService.List()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, tok, nil)
|
||||
jsonObj(c, rows, nil)
|
||||
}
|
||||
|
||||
// regenerateApiToken rotates the API token. Any central panel that had
|
||||
// the old value cached will start failing heartbeats until it is updated
|
||||
// with the new token — that's intentional, it's the whole point of rotation.
|
||||
func (a *SettingController) regenerateApiToken(c *gin.Context) {
|
||||
tok, err := a.settingService.RegenerateApiToken()
|
||||
func (a *SettingController) createApiToken(c *gin.Context) {
|
||||
form := &apiTokenCreateForm{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
row, err := a.apiTokenService.Create(form.Name)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, tok, nil)
|
||||
jsonObj(c, row, nil)
|
||||
}
|
||||
|
||||
func (a *SettingController) deleteApiToken(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.Delete(id))
|
||||
}
|
||||
|
||||
func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
form := &apiTokenEnabledForm{}
|
||||
if bindErr := c.ShouldBind(form); bindErr != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), bindErr)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabled(id, form.Enabled))
|
||||
}
|
||||
|
||||
@@ -9,29 +9,75 @@ import (
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// getRemoteIp extracts the real IP address from the request headers or remote address.
|
||||
func getRemoteIp(c *gin.Context) string {
|
||||
if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok {
|
||||
return ip
|
||||
remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr)
|
||||
if !ok {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
for _, part := range strings.Split(xff, ",") {
|
||||
if ip, ok := extractTrustedIP(part); ok {
|
||||
return ip
|
||||
if isTrustedProxy(remoteIP) {
|
||||
if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok {
|
||||
return ip
|
||||
}
|
||||
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
for _, part := range strings.Split(xff, ",") {
|
||||
if ip, ok := extractTrustedIP(part); ok {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ip, ok := extractTrustedIP(c.Request.RemoteAddr); ok {
|
||||
return ip
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
func isTrustedForwardedRequest(c *gin.Context) bool {
|
||||
remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr)
|
||||
return ok && isTrustedProxy(remoteIP)
|
||||
}
|
||||
|
||||
func isTrustedProxy(ip string) bool {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
trusted := trustedProxyCIDRs()
|
||||
for _, value := range strings.Split(trusted, ",") {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if prefix, err := netip.ParsePrefix(value); err == nil {
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if proxyIP, err := netip.ParseAddr(value); err == nil && proxyIP.Unmap() == addr.Unmap() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func trustedProxyCIDRs() (trusted string) {
|
||||
trusted = "127.0.0.1/32,::1/128"
|
||||
defer func() {
|
||||
_ = recover()
|
||||
}()
|
||||
settingService := service.SettingService{}
|
||||
if value, err := settingService.GetTrustedProxyCIDRs(); err == nil && strings.TrimSpace(value) != "" {
|
||||
trusted = value
|
||||
}
|
||||
return trusted
|
||||
}
|
||||
|
||||
func extractTrustedIP(value string) (string, bool) {
|
||||
|
||||
34
web/controller/util_test.go
Normal file
34
web/controller/util_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestGetRemoteIpIgnoresForwardedHeadersFromUntrustedRemote(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Request.RemoteAddr = "203.0.113.10:12345"
|
||||
c.Request.Header.Set("X-Real-IP", "198.51.100.9")
|
||||
c.Request.Header.Set("X-Forwarded-For", "198.51.100.8")
|
||||
|
||||
if got := getRemoteIp(c); got != "203.0.113.10" {
|
||||
t.Fatalf("remote IP = %q, want request remote address", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRemoteIpHonorsForwardedHeadersFromTrustedLoopbackProxy(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Request.RemoteAddr = "127.0.0.1:12345"
|
||||
c.Request.Header.Set("X-Forwarded-For", "198.51.100.8, 127.0.0.1")
|
||||
|
||||
if got := getRemoteIp(c); got != "198.51.100.8" {
|
||||
t.Fatalf("remote IP = %q, want forwarded client IP", got)
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,11 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
|
||||
|
||||
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
|
||||
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
|
||||
testURL, err := service.SanitizePublicHTTPURL(testURL, false)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON, mode)
|
||||
if err != nil {
|
||||
|
||||
@@ -21,13 +21,14 @@ type Msg struct {
|
||||
// AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.
|
||||
type AllSetting struct {
|
||||
// Web server settings
|
||||
WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
|
||||
WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
|
||||
WebPort int `json:"webPort" form:"webPort"` // Web server port number
|
||||
WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server
|
||||
WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server
|
||||
WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs
|
||||
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes
|
||||
WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
|
||||
WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
|
||||
WebPort int `json:"webPort" form:"webPort"` // Web server port number
|
||||
WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server
|
||||
WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server
|
||||
WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs
|
||||
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes
|
||||
TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"` // Trusted reverse proxy IPs/CIDRs for forwarded headers
|
||||
|
||||
// UI settings
|
||||
PageSize int `json:"pageSize" form:"pageSize"` // Number of items per page in lists
|
||||
@@ -74,6 +75,7 @@ type AllSetting struct {
|
||||
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` // Restart Xray when clients are auto-disabled by expiry/traffic limit
|
||||
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` // Encrypt subscription responses
|
||||
SubShowInfo bool `json:"subShowInfo" form:"subShowInfo"` // Show client information in subscriptions
|
||||
SubEmailInRemark bool `json:"subEmailInRemark" form:"subEmailInRemark"` // Include email in subscription remark/name
|
||||
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
|
||||
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
|
||||
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
|
||||
@@ -110,6 +112,20 @@ type AllSetting struct {
|
||||
// JSON subscription routing rules
|
||||
}
|
||||
|
||||
// AllSettingView is the browser-safe settings read model. Secret values
|
||||
// are redacted from the embedded write model and represented by presence
|
||||
// flags so the UI can show configured/not configured state.
|
||||
type AllSettingView struct {
|
||||
AllSetting
|
||||
|
||||
HasTgBotToken bool `json:"hasTgBotToken"`
|
||||
HasTwoFactorToken bool `json:"hasTwoFactorToken"`
|
||||
HasLdapPassword bool `json:"hasLdapPassword"`
|
||||
HasApiToken bool `json:"hasApiToken"`
|
||||
HasWarpSecret bool `json:"hasWarpSecret"`
|
||||
HasNordSecret bool `json:"hasNordSecret"`
|
||||
}
|
||||
|
||||
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
|
||||
func (s *AllSetting) CheckValid() error {
|
||||
if s.WebListen != "" {
|
||||
@@ -179,6 +195,19 @@ func (s *AllSetting) CheckValid() error {
|
||||
s.SubClashPath += "/"
|
||||
}
|
||||
|
||||
for _, cidr := range strings.Split(s.TrustedProxyCIDRs, ",") {
|
||||
cidr = strings.TrimSpace(cidr)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(cidr); ip != nil {
|
||||
continue
|
||||
}
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
return common.NewError("trusted proxy CIDR is not valid:", cidr)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := time.LoadLocation(s.TimeLocation)
|
||||
if err != nil {
|
||||
return common.NewError("time location not exist:", s.TimeLocation)
|
||||
|
||||
@@ -181,7 +181,7 @@ func (j *CheckClientIpJob) processLogFile() bool {
|
||||
var timestamp int64
|
||||
timestampMatches := timestampRegex.FindStringSubmatch(line)
|
||||
if len(timestampMatches) >= 2 {
|
||||
t, err := time.Parse("2006/01/02 15:04:05", timestampMatches[1])
|
||||
t, err := time.ParseInLocation("2006/01/02 15:04:05", timestampMatches[1], time.Local)
|
||||
if err == nil {
|
||||
timestamp = t.Unix()
|
||||
} else {
|
||||
|
||||
@@ -43,36 +43,6 @@ func (a *atomicBool) takeAndReset() bool {
|
||||
return v
|
||||
}
|
||||
|
||||
type emailSet struct {
|
||||
mu sync.Mutex
|
||||
m map[string]struct{}
|
||||
}
|
||||
|
||||
func newEmailSet() *emailSet { return &emailSet{m: make(map[string]struct{})} }
|
||||
|
||||
func (s *emailSet) addAll(emails []string) {
|
||||
if len(emails) == 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
for _, e := range emails {
|
||||
if e != "" {
|
||||
s.m[e] = struct{}{}
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *emailSet) slice() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]string, 0, len(s.m))
|
||||
for e := range s.m {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NewNodeTrafficSyncJob() *NodeTrafficSyncJob {
|
||||
return &NodeTrafficSyncJob{}
|
||||
}
|
||||
@@ -97,7 +67,6 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
return
|
||||
}
|
||||
|
||||
touched := newEmailSet()
|
||||
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, n := range nodes {
|
||||
@@ -109,7 +78,7 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
go func(n *model.Node) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
j.syncOne(mgr, n, touched)
|
||||
j.syncOne(mgr, n)
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -135,12 +104,10 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
})
|
||||
|
||||
clientStats := map[string]any{}
|
||||
if emails := touched.slice(); len(emails) > 0 {
|
||||
if stats, err := j.inboundService.GetActiveClientTraffics(emails); err != nil {
|
||||
logger.Warning("node traffic sync: get client traffics for websocket failed:", err)
|
||||
} else if len(stats) > 0 {
|
||||
clientStats["clients"] = stats
|
||||
}
|
||||
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
|
||||
logger.Warning("node traffic sync: get all client traffics for websocket failed:", err)
|
||||
} else if len(stats) > 0 {
|
||||
clientStats["clients"] = stats
|
||||
}
|
||||
if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
||||
logger.Warning("node traffic sync: get inbounds summary for websocket failed:", err)
|
||||
@@ -156,7 +123,7 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
}
|
||||
}
|
||||
|
||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, touched *emailSet) {
|
||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -179,16 +146,4 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, touche
|
||||
if changed {
|
||||
j.structural.set()
|
||||
}
|
||||
for _, ib := range snap.Inbounds {
|
||||
if ib == nil {
|
||||
continue
|
||||
}
|
||||
emails := make([]string, 0, len(ib.ClientStats))
|
||||
for _, cs := range ib.ClientStats {
|
||||
if cs.Email != "" {
|
||||
emails = append(emails, cs.Email)
|
||||
}
|
||||
}
|
||||
touched.addAll(emails)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,18 +95,16 @@ func (j *XrayTrafficJob) Run() {
|
||||
"lastOnlineMap": lastOnlineMap,
|
||||
})
|
||||
|
||||
// Compact delta payload: per-client absolute counters for clients active
|
||||
// this cycle, plus inbound-level absolute totals. Frontend applies both
|
||||
// in-place — typical payload ~10–50KB even for 10k+ client deployments.
|
||||
// Replaces the old full-inbound-list broadcast that hit WS size limits
|
||||
// (5–10MB) and forced the frontend into a REST refetch.
|
||||
// Full snapshot every cycle: absolute per-client counters and inbound
|
||||
// totals. Frontend overwrites both in place. The previous delta path
|
||||
// (activeEmails -> GetActiveClientTraffics) silently omitted the
|
||||
// clients array whenever nobody moved bytes in the cycle, leaving the
|
||||
// client rows in the UI stuck at stale traffic/remained/all-time.
|
||||
clientStatsPayload := map[string]any{}
|
||||
if activeEmails := activeEmails(clientTraffics); len(activeEmails) > 0 {
|
||||
if stats, err := j.inboundService.GetActiveClientTraffics(activeEmails); err != nil {
|
||||
logger.Warning("get active client traffics for websocket failed:", err)
|
||||
} else if len(stats) > 0 {
|
||||
clientStatsPayload["clients"] = stats
|
||||
}
|
||||
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
|
||||
logger.Warning("get all client traffics for websocket failed:", err)
|
||||
} else if len(stats) > 0 {
|
||||
clientStatsPayload["clients"] = stats
|
||||
}
|
||||
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
||||
logger.Warning("get inbounds traffic summary for websocket failed:", err)
|
||||
@@ -126,32 +124,17 @@ func (j *XrayTrafficJob) Run() {
|
||||
}
|
||||
}
|
||||
|
||||
// activeEmails returns the set of client emails that had non-zero traffic in
|
||||
// the current collection window. Idle clients are skipped — no need to push
|
||||
// their (unchanged) counters to the frontend.
|
||||
func activeEmails(clientTraffics []*xray.ClientTraffic) []string {
|
||||
if len(clientTraffics) == 0 {
|
||||
return nil
|
||||
}
|
||||
emails := make([]string, 0, len(clientTraffics))
|
||||
for _, ct := range clientTraffics {
|
||||
if ct == nil || ct.Email == "" {
|
||||
continue
|
||||
}
|
||||
if ct.Up == 0 && ct.Down == 0 {
|
||||
continue
|
||||
}
|
||||
emails = append(emails, ct.Email)
|
||||
}
|
||||
return emails
|
||||
}
|
||||
|
||||
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
||||
informURL, err := j.settingService.GetExternalTrafficInformURI()
|
||||
if err != nil {
|
||||
logger.Warning("get ExternalTrafficInformURI failed:", err)
|
||||
return
|
||||
}
|
||||
informURL, err = service.SanitizePublicHTTPURL(informURL, false)
|
||||
if err != nil {
|
||||
logger.Warning("ExternalTrafficInformURI blocked:", err)
|
||||
return
|
||||
}
|
||||
requestBody, err := json.Marshal(map[string]any{"clientTraffics": clientTraffics, "inboundTraffics": inboundTraffics})
|
||||
if err != nil {
|
||||
logger.Warning("parse client/inbound traffic failed:", err)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/web/session"
|
||||
@@ -11,10 +13,12 @@ import (
|
||||
// SecurityHeadersMiddleware adds browser hardening headers to panel responses.
|
||||
func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
nonce := newCSPNonce()
|
||||
c.Set("csp_nonce", nonce)
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("Content-Security-Policy", "frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
|
||||
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'nonce-"+nonce+"'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
|
||||
if directHTTPS {
|
||||
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
@@ -22,6 +26,14 @@ func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func newCSPNonce() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.RawStdEncoding.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// CSRFMiddleware rejects unsafe requests that do not include the session CSRF token.
|
||||
// Bearer-token-authenticated callers (api_authed flag set by APIController.checkAPIAuth)
|
||||
// short-circuit the CSRF check — they are not browser sessions, so the
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/netsafe"
|
||||
)
|
||||
|
||||
const remoteHTTPTimeout = 10 * time.Second
|
||||
@@ -25,6 +27,7 @@ var remoteHTTPClient = &http.Client{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -50,7 +53,18 @@ func NewRemote(n *model.Node) *Remote {
|
||||
|
||||
func (r *Remote) Name() string { return "node:" + r.node.Name }
|
||||
|
||||
func (r *Remote) baseURL() string {
|
||||
func (r *Remote) baseURL() (string, error) {
|
||||
addr, err := netsafe.NormalizeHost(r.node.Address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
scheme := r.node.Scheme
|
||||
if scheme != "http" && scheme != "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
if r.node.Port <= 0 || r.node.Port > 65535 {
|
||||
return "", fmt.Errorf("invalid node port %d", r.node.Port)
|
||||
}
|
||||
bp := r.node.BasePath
|
||||
if bp == "" {
|
||||
bp = "/"
|
||||
@@ -58,7 +72,12 @@ func (r *Remote) baseURL() string {
|
||||
if !strings.HasSuffix(bp, "/") {
|
||||
bp += "/"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s:%d%s", r.node.Scheme, r.node.Address, r.node.Port, bp)
|
||||
u := &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: net.JoinHostPort(addr, strconv.Itoa(r.node.Port)),
|
||||
Path: bp,
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelope, error) {
|
||||
@@ -66,7 +85,11 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
|
||||
return nil, errors.New("node has no API token configured")
|
||||
}
|
||||
|
||||
target := r.baseURL() + strings.TrimPrefix(path, "/")
|
||||
base, err := r.baseURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := base + strings.TrimPrefix(path, "/")
|
||||
|
||||
var (
|
||||
reqBody io.Reader
|
||||
@@ -78,15 +101,15 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
|
||||
reqBody = strings.NewReader(b.Encode())
|
||||
contentType = "application/x-www-form-urlencoded"
|
||||
default:
|
||||
buf, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal body: %w", err)
|
||||
buf, jerr := json.Marshal(b)
|
||||
if jerr != nil {
|
||||
return nil, fmt.Errorf("marshal body: %w", jerr)
|
||||
}
|
||||
reqBody = bytes.NewReader(buf)
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, remoteHTTPTimeout)
|
||||
cctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(ctx, r.node.AllowPrivateAddress), remoteHTTPTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(cctx, method, target, reqBody)
|
||||
if err != nil {
|
||||
@@ -311,7 +334,7 @@ func wireInbound(ib *model.Inbound) url.Values {
|
||||
v.Set("port", strconv.Itoa(ib.Port))
|
||||
v.Set("protocol", string(ib.Protocol))
|
||||
v.Set("settings", ib.Settings)
|
||||
v.Set("streamSettings", ib.StreamSettings)
|
||||
v.Set("streamSettings", sanitizeStreamSettingsForRemote(ib.StreamSettings))
|
||||
v.Set("tag", ib.Tag)
|
||||
v.Set("sniffing", ib.Sniffing)
|
||||
if ib.TrafficReset != "" {
|
||||
@@ -319,3 +342,44 @@ func wireInbound(ib *model.Inbound) url.Values {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// sanitizeStreamSettingsForRemote strips file-based TLS certificate paths
|
||||
// from the StreamSettings before sending to a remote node. File paths
|
||||
// (certificateFile / keyFile) are local to the main panel's filesystem
|
||||
// and will cause Xray on the remote node to crash if they don't exist there.
|
||||
// Inline certificate content (certificate / key) is kept intact.
|
||||
func sanitizeStreamSettingsForRemote(streamSettings string) string {
|
||||
if streamSettings == "" {
|
||||
return streamSettings
|
||||
}
|
||||
|
||||
var stream map[string]any
|
||||
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
|
||||
return streamSettings
|
||||
}
|
||||
|
||||
tlsSettings, ok := stream["tlsSettings"].(map[string]any)
|
||||
if !ok {
|
||||
return streamSettings
|
||||
}
|
||||
|
||||
certificates, ok := tlsSettings["certificates"].([]any)
|
||||
if !ok {
|
||||
return streamSettings
|
||||
}
|
||||
|
||||
for _, cert := range certificates {
|
||||
c, ok := cert.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(c, "certificateFile")
|
||||
delete(c, "keyFile")
|
||||
}
|
||||
|
||||
out, err := json.Marshal(stream)
|
||||
if err != nil {
|
||||
return streamSettings
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
119
web/service/api_token.go
Normal file
119
web/service/api_token.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/random"
|
||||
)
|
||||
|
||||
type ApiTokenService struct{}
|
||||
|
||||
const apiTokenLength = 48
|
||||
|
||||
type ApiTokenView struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
func toView(t *model.ApiToken) *ApiTokenView {
|
||||
return &ApiTokenView{
|
||||
Id: t.Id,
|
||||
Name: t.Name,
|
||||
Token: t.Token,
|
||||
Enabled: t.Enabled,
|
||||
CreatedAt: t.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
|
||||
db := database.GetDB()
|
||||
var rows []*model.ApiToken
|
||||
if err := db.Model(model.ApiToken{}).Order("id asc").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*ApiTokenView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toView(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, common.NewError("token name is required")
|
||||
}
|
||||
if len(name) > 64 {
|
||||
return nil, common.NewError("token name must be 64 characters or fewer")
|
||||
}
|
||||
db := database.GetDB()
|
||||
var count int64
|
||||
if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, common.NewError("a token with that name already exists")
|
||||
}
|
||||
row := &model.ApiToken{
|
||||
Name: name,
|
||||
Token: random.Seq(apiTokenLength),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toView(row), nil
|
||||
}
|
||||
|
||||
func (s *ApiTokenService) Delete(id int) error {
|
||||
if id <= 0 {
|
||||
return common.NewError("invalid token id")
|
||||
}
|
||||
db := database.GetDB()
|
||||
return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
|
||||
}
|
||||
|
||||
func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
|
||||
if id <= 0 {
|
||||
return common.NewError("invalid token id")
|
||||
}
|
||||
db := database.GetDB()
|
||||
res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("token not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match returns true when the presented bearer token matches any enabled
|
||||
// row in api_tokens. Uses constant-time compare per row so a remote
|
||||
// attacker can't time-attack tokens byte-by-byte.
|
||||
func (s *ApiTokenService) Match(presented string) bool {
|
||||
if presented == "" {
|
||||
return false
|
||||
}
|
||||
db := database.GetDB()
|
||||
var rows []*model.ApiToken
|
||||
if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
presentedBytes := []byte(presented)
|
||||
matched := false
|
||||
for _, r := range rows {
|
||||
if subtle.ConstantTimeCompare([]byte(r.Token), presentedBytes) == 1 {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
@@ -1,4 +1,21 @@
|
||||
{
|
||||
"api": {
|
||||
"services": [
|
||||
"HandlerService",
|
||||
"LoggerService",
|
||||
"StatsService"
|
||||
],
|
||||
"tag": "api"
|
||||
},
|
||||
"inbounds": [{
|
||||
"listen": "127.0.0.1",
|
||||
"port": 62789,
|
||||
"protocol": "tunnel",
|
||||
"settings": {
|
||||
"rewriteAddress": "127.0.0.1"
|
||||
},
|
||||
"tag": "api"
|
||||
}],
|
||||
"log": {
|
||||
"access": "none",
|
||||
"dnsLog": false,
|
||||
@@ -6,39 +23,21 @@
|
||||
"loglevel": "warning",
|
||||
"maskAddress": ""
|
||||
},
|
||||
"api": {
|
||||
"tag": "api",
|
||||
"services": [
|
||||
"HandlerService",
|
||||
"LoggerService",
|
||||
"StatsService"
|
||||
]
|
||||
"metrics": {
|
||||
"listen": "127.0.0.1:11111",
|
||||
"tag": "metrics_out"
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"tag": "api",
|
||||
"listen": "127.0.0.1",
|
||||
"port": 62789,
|
||||
"protocol": "tunnel",
|
||||
"settings": {
|
||||
"address": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"tag": "direct",
|
||||
"outbounds": [{
|
||||
"protocol": "freedom",
|
||||
"settings": {
|
||||
"domainStrategy": "AsIs",
|
||||
"redirect": "",
|
||||
"noises": []
|
||||
}
|
||||
"domainStrategy": "AsIs"
|
||||
},
|
||||
"tag": "direct"
|
||||
},
|
||||
{
|
||||
"tag": "blocked",
|
||||
"protocol": "blackhole",
|
||||
"settings": {}
|
||||
"settings": {},
|
||||
"tag": "blocked"
|
||||
}
|
||||
],
|
||||
"policy": {
|
||||
@@ -57,33 +56,28 @@
|
||||
},
|
||||
"routing": {
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": [
|
||||
{
|
||||
"type": "field",
|
||||
"rules": [{
|
||||
"inboundTag": [
|
||||
"api"
|
||||
],
|
||||
"outboundTag": "api"
|
||||
"outboundTag": "api",
|
||||
"type": "field"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"outboundTag": "blocked",
|
||||
"ip": [
|
||||
"geoip:private"
|
||||
]
|
||||
],
|
||||
"outboundTag": "blocked",
|
||||
"type": "field"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"outboundTag": "blocked",
|
||||
"protocol": [
|
||||
"bittorrent"
|
||||
]
|
||||
],
|
||||
"type": "field"
|
||||
}
|
||||
]
|
||||
},
|
||||
"stats": {},
|
||||
"metrics": {
|
||||
"tag": "metrics_out",
|
||||
"listen": "127.0.0.1:11111"
|
||||
}
|
||||
}
|
||||
"stats": {}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/netsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -164,8 +165,7 @@ func CustomGeoLocalFileNeedsRepair(path string) bool {
|
||||
}
|
||||
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
|
||||
return netsafe.IsBlockedIP(ip)
|
||||
}
|
||||
|
||||
// checkSSRFDefault validates that the given host does not resolve to a private/internal IP.
|
||||
|
||||
@@ -849,6 +849,9 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
|
||||
|
||||
// Secure client ID
|
||||
for _, client := range clients {
|
||||
if strings.TrimSpace(client.Email) == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
switch oldInbound.Protocol {
|
||||
case "trojan":
|
||||
if client.Password == "" {
|
||||
@@ -1317,8 +1320,11 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
|
||||
if newClientId == "" || clientIndex == -1 {
|
||||
return false, common.NewError("empty client ID")
|
||||
}
|
||||
if strings.TrimSpace(clients[0].Email) == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
|
||||
if len(clients[0].Email) > 0 && clients[0].Email != oldEmail {
|
||||
if clients[0].Email != oldEmail {
|
||||
existEmail, err := s.checkEmailsExistForClients(clients)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -2036,6 +2042,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
|
||||
traffics[traffic_index].Up = 0
|
||||
if !traffic.Enable {
|
||||
traffics[traffic_index].Enable = true
|
||||
c["enable"] = true
|
||||
clientsToAdd = append(clientsToAdd,
|
||||
struct {
|
||||
protocol string
|
||||
@@ -3315,6 +3322,20 @@ func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.Clien
|
||||
return traffics, nil
|
||||
}
|
||||
|
||||
// GetAllClientTraffics returns the full set of client_traffics rows so the
|
||||
// websocket broadcasters can ship a complete snapshot every cycle. The old
|
||||
// delta-only path (GetActiveClientTraffics on activeEmails) silently dropped
|
||||
// the per-client section whenever no client moved bytes in the cycle or a
|
||||
// node sync failed, leaving client rows in the UI stuck at stale numbers.
|
||||
func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) {
|
||||
db := database.GetDB()
|
||||
var traffics []*xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Find(&traffics).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return traffics, nil
|
||||
}
|
||||
|
||||
type InboundTrafficSummary struct {
|
||||
Id int `json:"id"`
|
||||
Up int64 `json:"up"`
|
||||
|
||||
@@ -130,6 +130,7 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
|
||||
var (
|
||||
systemMetrics = newMetricHistory()
|
||||
nodeMetrics = newMetricHistory()
|
||||
xrayMetrics = newMetricHistory()
|
||||
)
|
||||
|
||||
// SystemMetricKeys lists the metric names ServerService writes on every
|
||||
@@ -141,3 +142,11 @@ var SystemMetricKeys = []string{
|
||||
|
||||
// NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
|
||||
var NodeMetricKeys = []string{"cpu", "mem"}
|
||||
|
||||
// XrayMetricKeys lists series sourced from xray's /debug/vars expvar
|
||||
// endpoint. Populated by XrayMetricsService.Sample on the same 2s cadence
|
||||
// as the system metrics, but only when the xray config has a `metrics`
|
||||
// block configured.
|
||||
var XrayMetricKeys = []string{
|
||||
"xrAlloc", "xrSys", "xrHeapObjects", "xrNumGC", "xrPauseNs",
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/netsafe"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/runtime"
|
||||
)
|
||||
|
||||
@@ -34,6 +37,7 @@ var nodeHTTPClient = &http.Client{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -53,31 +57,38 @@ func (s *NodeService) GetById(id int) (*model.Node, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func normalizeBasePath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
if !strings.HasSuffix(p, "/") {
|
||||
p = p + "/"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (s *NodeService) normalize(n *model.Node) error {
|
||||
n.Name = strings.TrimSpace(n.Name)
|
||||
n.Address = strings.TrimSpace(n.Address)
|
||||
n.ApiToken = strings.TrimSpace(n.ApiToken)
|
||||
if n.Name == "" {
|
||||
return common.NewError("node name is required")
|
||||
}
|
||||
if n.Address == "" {
|
||||
return common.NewError("node address is required")
|
||||
addr, err := netsafe.NormalizeHost(n.Address)
|
||||
if err != nil {
|
||||
return common.NewError(err.Error())
|
||||
}
|
||||
n.Address = addr
|
||||
if n.Port <= 0 || n.Port > 65535 {
|
||||
return common.NewError("node port must be 1-65535")
|
||||
}
|
||||
if n.Scheme != "http" && n.Scheme != "https" {
|
||||
n.Scheme = "https"
|
||||
}
|
||||
if n.BasePath == "" {
|
||||
n.BasePath = "/"
|
||||
}
|
||||
if !strings.HasPrefix(n.BasePath, "/") {
|
||||
n.BasePath = "/" + n.BasePath
|
||||
}
|
||||
if !strings.HasSuffix(n.BasePath, "/") {
|
||||
n.BasePath = n.BasePath + "/"
|
||||
}
|
||||
n.BasePath = normalizeBasePath(n.BasePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -99,14 +110,15 @@ func (s *NodeService) Update(id int, in *model.Node) error {
|
||||
return err
|
||||
}
|
||||
updates := map[string]any{
|
||||
"name": in.Name,
|
||||
"remark": in.Remark,
|
||||
"scheme": in.Scheme,
|
||||
"address": in.Address,
|
||||
"port": in.Port,
|
||||
"base_path": in.BasePath,
|
||||
"api_token": in.ApiToken,
|
||||
"enable": in.Enable,
|
||||
"name": in.Name,
|
||||
"remark": in.Remark,
|
||||
"scheme": in.Scheme,
|
||||
"address": in.Address,
|
||||
"port": in.Port,
|
||||
"base_path": in.BasePath,
|
||||
"api_token": in.ApiToken,
|
||||
"enable": in.Enable,
|
||||
"allow_private_address": in.AllowPrivateAddress,
|
||||
}
|
||||
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
@@ -168,10 +180,29 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i
|
||||
|
||||
func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
|
||||
patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()}
|
||||
url := fmt.Sprintf("%s://%s:%d%spanel/api/server/status",
|
||||
n.Scheme, n.Address, n.Port, n.BasePath)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
addr, err := netsafe.NormalizeHost(n.Address)
|
||||
if err != nil {
|
||||
patch.LastError = err.Error()
|
||||
return patch, err
|
||||
}
|
||||
scheme := n.Scheme
|
||||
if scheme != "http" && scheme != "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
if n.Port <= 0 || n.Port > 65535 {
|
||||
patch.LastError = "node port must be 1-65535"
|
||||
return patch, errors.New(patch.LastError)
|
||||
}
|
||||
probeURL := &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: net.JoinHostPort(addr, strconv.Itoa(n.Port)),
|
||||
Path: normalizeBasePath(n.BasePath) + "panel/api/server/status",
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
|
||||
http.MethodGet, probeURL.String(), nil)
|
||||
if err != nil {
|
||||
patch.LastError = err.Error()
|
||||
return patch, err
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user