Compare commits

...

16 commits

Author SHA1 Message Date
javadtgh
5d93eae438
Merge 5e953bae45 into 22afa50901 2025-09-17 02:09:15 +03:00
mhsanaei
22afa50901
fix CPU History intervals
Some checks are pending
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
2025-09-17 01:08:59 +02:00
mhsanaei
bc274d1e1f
Reality: placeholder for min, max 2025-09-16 18:57:27 +02:00
mhsanaei
dc21f41932
bug fix: del Depleted 2025-09-16 18:28:02 +02:00
mhsanaei
f137b1af76
bug fix: enable 2025-09-16 14:57:31 +02:00
mhsanaei
c4871ef8fe
sub page: improved 2025-09-16 14:38:18 +02:00
mhsanaei
ecfffa882a
CPU History, CPU Utilization 2025-09-16 14:15:18 +02:00
mhsanaei
3af5026abe
tgbot: subscription, qrcode, link - for admin 2025-09-16 13:41:48 +02:00
mhsanaei
1de7accd7c
vnext removed 2025-09-16 13:41:05 +02:00
Sanaei
5e953bae45
Merge branch 'main' into feature/multi-server-support 2025-09-12 12:17:12 +02:00
Sanaei
747af376f2
Merge branch 'main' into feature/multi-server-support 2025-09-09 20:53:50 +02:00
Sanaei
a3ccccfe52
Merge branch 'main' into feature/multi-server-support 2025-09-08 14:45:59 +02:00
Sanaei
3299d15f28
Merge branch 'main' into feature/multi-server-support 2025-08-14 18:06:16 +02:00
Sanaei
ae82373457
Merge branch 'main' into feature/multi-server-support 2025-08-04 11:22:53 +02:00
Sanaei
d65233cc2c
Merge branch 'main' into feature/multi-server-support 2025-08-04 10:33:41 +02:00
google-labs-jules[bot]
11dc06863e feat: Add multi-server support for Sanai panel
This commit introduces a multi-server architecture to the Sanai panel, allowing you to manage clients across multiple servers from a central panel.

Key changes include:

- **Database Schema:** Added a `servers` table to store information about slave servers.
- **Server Management:** Implemented a new service and controller (`MultiServerService` and `MultiServerController`) for CRUD operations on servers.
- **Web UI:** Created a new web page for managing servers, accessible from the sidebar.
- **Client Synchronization:** Modified the `InboundService` to synchronize client additions, updates, and deletions across all active slave servers via a REST API.
- **API Security:** Added an API key authentication middleware to secure the communication between the master and slave panels.
- **Multi-Server Subscriptions:** Updated the subscription service to generate links that include configurations for all active servers.
- **Installation Script:** Modified the `install.sh` script to generate a random API key during installation.

**Known Issues:**

- The integration test for client synchronization (`TestInboundServiceSync`) is currently failing. It seems that the API request to the mock slave server is not being sent correctly or the API key is not being included in the request header. Further investigation is needed to resolve this issue.
2025-07-27 17:25:58 +02:00
35 changed files with 3245 additions and 1714 deletions

View file

@ -35,6 +35,7 @@ func initModels() error {
&model.InboundClientIps{}, &model.InboundClientIps{},
&xray.ClientTraffic{}, &xray.ClientTraffic{},
&model.HistoryOfSeeders{}, &model.HistoryOfSeeders{},
&model.Server{},
} }
for _, model := range models { for _, model := range models {
if err := db.AutoMigrate(model); err != nil { if err := db.AutoMigrate(model); err != nil {

View file

@ -115,3 +115,12 @@ type VLESSSettings struct {
Encryption string `json:"encryption"` Encryption string `json:"encryption"`
Fallbacks []any `json:"fallbacks"` Fallbacks []any `json:"fallbacks"`
} }
type Server struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"unique;not null"`
Address string `json:"address" gorm:"not null"`
Port int `json:"port" gorm:"not null"`
APIKey string `json:"apiKey" gorm:"not null"`
Enable bool `json:"enable" gorm:"default:true"`
}

3
go.mod
View file

@ -21,6 +21,7 @@ require (
github.com/xtls/xray-core v1.250911.0 github.com/xtls/xray-core v1.250911.0
go.uber.org/atomic v1.11.0 go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.42.0 golang.org/x/crypto v0.42.0
golang.org/x/sys v0.36.0
golang.org/x/text v0.29.0 golang.org/x/text v0.29.0
google.golang.org/grpc v1.75.1 google.golang.org/grpc v1.75.1
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
@ -63,6 +64,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pires/go-proxyproto v0.8.1 // indirect github.com/pires/go-proxyproto v0.8.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect github.com/quic-go/quic-go v0.54.0 // indirect
@ -89,7 +91,6 @@ require (
golang.org/x/mod v0.28.0 // indirect golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.44.0 // indirect golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/time v0.13.0 // indirect golang.org/x/time v0.13.0 // indirect
golang.org/x/tools v0.36.0 // indirect golang.org/x/tools v0.36.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect

View file

@ -137,6 +137,13 @@ config_after_install() {
fi fi
/usr/local/x-ui/x-ui migrate /usr/local/x-ui/x-ui migrate
local existing_apiKey=$(/usr/local/x-ui/x-ui setting -show true | grep -oP 'ApiKey: \K.*')
if [[ -z "$existing_apiKey" ]]; then
local config_apiKey=$(gen_random_string 32)
/usr/local/x-ui/x-ui setting -apiKey "${config_apiKey}"
echo -e "${green}Generated random API Key: ${config_apiKey}${plain}"
fi
} }
install_x-ui() { install_x-ui() {

15
main.go
View file

@ -232,7 +232,7 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri
} }
} }
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) { func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool, apiKey string) {
err := database.InitDB(config.GetDBPath()) err := database.InitDB(config.GetDBPath())
if err != nil { if err != nil {
fmt.Println("Database initialization failed:", err) fmt.Println("Database initialization failed:", err)
@ -242,6 +242,15 @@ func updateSetting(port int, username string, password string, webBasePath strin
settingService := service.SettingService{} settingService := service.SettingService{}
userService := service.UserService{} userService := service.UserService{}
if apiKey != "" {
err := settingService.SetAPIKey(apiKey)
if err != nil {
fmt.Println("Failed to set API Key:", err)
} else {
fmt.Printf("API Key set successfully: %v\n", apiKey)
}
}
if port > 0 { if port > 0 {
err := settingService.SetPort(port) err := settingService.SetPort(port)
if err != nil { if err != nil {
@ -388,9 +397,11 @@ func main() {
var show bool var show bool
var getCert bool var getCert bool
var resetTwoFactor bool var resetTwoFactor bool
var apiKey string
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings") settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
settingCmd.BoolVar(&show, "show", false, "Display current settings") settingCmd.BoolVar(&show, "show", false, "Display current settings")
settingCmd.IntVar(&port, "port", 0, "Set panel port number") settingCmd.IntVar(&port, "port", 0, "Set panel port number")
settingCmd.StringVar(&apiKey, "apiKey", "", "Set API Key")
settingCmd.StringVar(&username, "username", "", "Set login username") settingCmd.StringVar(&username, "username", "", "Set login username")
settingCmd.StringVar(&password, "password", "", "Set login password") settingCmd.StringVar(&password, "password", "", "Set login password")
settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel") settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel")
@ -440,7 +451,7 @@ func main() {
if reset { if reset {
resetSetting() resetSetting()
} else { } else {
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor) updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor, apiKey)
} }
if show { if show {
showSetting(show) showSetting(show)

View file

@ -11,6 +11,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"x-ui/logger" "x-ui/logger"
"x-ui/util/common" "x-ui/util/common"
@ -74,11 +75,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
engine.Use(middleware.DomainValidatorMiddleware(subDomain)) engine.Use(middleware.DomainValidatorMiddleware(subDomain))
} }
// Provide base_path in context for templates
engine.Use(func(c *gin.Context) {
c.Set("base_path", "/")
})
LinksPath, err := s.settingService.GetSubPath() LinksPath, err := s.settingService.GetSubPath()
if err != nil { if err != nil {
return nil, err return nil, err
@ -89,6 +85,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
return nil, err return nil, err
} }
// Set base_path based on LinksPath for template rendering
engine.Use(func(c *gin.Context) {
c.Set("base_path", LinksPath)
})
Encrypt, err := s.settingService.GetSubEncrypt() Encrypt, err := s.settingService.GetSubEncrypt()
if err != nil { if err != nil {
return nil, err return nil, err
@ -154,11 +155,29 @@ func (s *Server) initRouter() (*gin.Engine, error) {
} }
// Assets: use disk if present, fallback to embedded // Assets: use disk if present, fallback to embedded
// Serve under both root (/assets) and under the subscription path prefix (LinksPath + "assets")
// so reverse proxies with a URI prefix can load assets correctly.
// Determine LinksPath earlier to compute prefixed assets mount.
// Note: LinksPath always starts and ends with "/" (validated in settings).
var linksPathForAssets string
if LinksPath == "/" {
linksPathForAssets = "/assets"
} else {
// ensure single slash join
linksPathForAssets = strings.TrimRight(LinksPath, "/") + "/assets"
}
if _, err := os.Stat("web/assets"); err == nil { if _, err := os.Stat("web/assets"); err == nil {
engine.StaticFS("/assets", http.FS(os.DirFS("web/assets"))) engine.StaticFS("/assets", http.FS(os.DirFS("web/assets")))
if linksPathForAssets != "/assets" {
engine.StaticFS(linksPathForAssets, http.FS(os.DirFS("web/assets")))
}
} else { } else {
if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil { if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil {
engine.StaticFS("/assets", http.FS(subFS)) engine.StaticFS("/assets", http.FS(subFS))
if linksPathForAssets != "/assets" {
engine.StaticFS(linksPathForAssets, http.FS(subFS))
}
} else { } else {
logger.Error("sub: failed to mount embedded assets:", err) logger.Error("sub: failed to mount embedded assets:", err)
} }

View file

@ -292,34 +292,25 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, encryption string) json_util.RawMessage { func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, encryption string) json_util.RawMessage {
outbound := Outbound{} outbound := Outbound{}
usersData := make([]UserVnext, 1)
usersData[0].ID = client.ID
usersData[0].Level = 8
if inbound.Protocol == model.VMESS {
usersData[0].Security = client.Security
}
if inbound.Protocol == model.VLESS {
usersData[0].Flow = client.Flow
usersData[0].Encryption = encryption
}
vnextData := make([]VnextSetting, 1)
vnextData[0] = VnextSetting{
Address: inbound.Listen,
Port: inbound.Port,
Users: usersData,
}
outbound.Protocol = string(inbound.Protocol) outbound.Protocol = string(inbound.Protocol)
outbound.Tag = "proxy" outbound.Tag = "proxy"
if s.mux != "" { if s.mux != "" {
outbound.Mux = json_util.RawMessage(s.mux) outbound.Mux = json_util.RawMessage(s.mux)
} }
outbound.StreamSettings = streamSettings outbound.StreamSettings = streamSettings
outbound.Settings = OutboundSettings{ // Emit flattened settings inside Settings to match new Xray format
Vnext: vnextData, settings := make(map[string]any)
settings["address"] = inbound.Listen
settings["port"] = inbound.Port
settings["id"] = client.ID
if inbound.Protocol == model.VLESS {
settings["flow"] = client.Flow
settings["encryption"] = encryption
} }
if inbound.Protocol == model.VMESS {
settings["security"] = client.Security
}
outbound.Settings = settings
result, _ := json.MarshalIndent(outbound, "", " ") result, _ := json.MarshalIndent(outbound, "", " ")
return result return result
@ -356,8 +347,8 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
outbound.Mux = json_util.RawMessage(s.mux) outbound.Mux = json_util.RawMessage(s.mux)
} }
outbound.StreamSettings = streamSettings outbound.StreamSettings = streamSettings
outbound.Settings = OutboundSettings{ outbound.Settings = map[string]any{
Servers: serverData, "servers": serverData,
} }
result, _ := json.MarshalIndent(outbound, "", " ") result, _ := json.MarshalIndent(outbound, "", " ")
@ -369,28 +360,10 @@ type Outbound struct {
Tag string `json:"tag"` Tag string `json:"tag"`
StreamSettings json_util.RawMessage `json:"streamSettings"` StreamSettings json_util.RawMessage `json:"streamSettings"`
Mux json_util.RawMessage `json:"mux,omitempty"` Mux json_util.RawMessage `json:"mux,omitempty"`
ProxySettings map[string]any `json:"proxySettings,omitempty"` Settings map[string]any `json:"settings,omitempty"`
Settings OutboundSettings `json:"settings,omitempty"`
} }
type OutboundSettings struct { // Legacy vnext-related structs removed for flattened schema
Vnext []VnextSetting `json:"vnext,omitempty"`
Servers []ServerSetting `json:"servers,omitempty"`
}
type VnextSetting struct {
Address string `json:"address"`
Port int `json:"port"`
Users []UserVnext `json:"users"`
}
type UserVnext struct {
Encryption string `json:"encryption,omitempty"`
Flow string `json:"flow,omitempty"`
ID string `json:"id"`
Security string `json:"security,omitempty"`
Level int `json:"level"`
}
type ServerSetting struct { type ServerSetting struct {
Password string `json:"password"` Password string `json:"password"`

View file

@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"net" "net"
"net/url" "net/url"
"strconv"
"strings" "strings"
"time" "time"
@ -160,26 +159,43 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
} }
func (s *SubService) getLink(inbound *model.Inbound, email string) string { func (s *SubService) getLink(inbound *model.Inbound, email string) string {
serverService := service.MultiServerService{}
servers, err := serverService.GetServers()
if err != nil {
logger.Warning("Failed to get servers for subscription:", err)
return ""
}
var links []string
for _, server := range servers {
if !server.Enable {
continue
}
var link string
switch inbound.Protocol { switch inbound.Protocol {
case "vmess": case "vmess":
return s.genVmessLink(inbound, email) link = s.genVmessLink(inbound, email, server)
case "vless": case "vless":
return s.genVlessLink(inbound, email) link = s.genVlessLink(inbound, email, server)
case "trojan": case "trojan":
return s.genTrojanLink(inbound, email) link = s.genTrojanLink(inbound, email, server)
case "shadowsocks": case "shadowsocks":
return s.genShadowsocksLink(inbound, email) link = s.genShadowsocksLink(inbound, email, server)
} }
return "" if link != "" {
links = append(links, link)
}
}
return strings.Join(links, "\n")
} }
func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { func (s *SubService) genVmessLink(inbound *model.Inbound, email string, server *model.Server) string {
if inbound.Protocol != model.VMESS { if inbound.Protocol != model.VMESS {
return "" return ""
} }
obj := map[string]any{ obj := map[string]any{
"v": "2", "v": "2",
"add": s.address, "add": server.Address,
"port": inbound.Port, "port": inbound.Port,
"type": "none", "type": "none",
} }
@ -292,7 +308,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
newObj[key] = value newObj[key] = value
} }
} }
newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string)) newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string), server.Name)
newObj["add"] = ep["dest"].(string) newObj["add"] = ep["dest"].(string)
newObj["port"] = int(ep["port"].(float64)) newObj["port"] = int(ep["port"].(float64))
@ -308,14 +324,14 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
return links return links
} }
obj["ps"] = s.genRemark(inbound, email, "") obj["ps"] = s.genRemark(inbound, email, "", server.Name)
jsonStr, _ := json.MarshalIndent(obj, "", " ") jsonStr, _ := json.MarshalIndent(obj, "", " ")
return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
} }
func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { func (s *SubService) genVlessLink(inbound *model.Inbound, email string, server *model.Server) string {
address := s.address address := server.Address
if inbound.Protocol != model.VLESS { if inbound.Protocol != model.VLESS {
return "" return ""
} }
@ -494,7 +510,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) url.Fragment = s.genRemark(inbound, email, ep["remark"].(string), server.Name)
if index > 0 { if index > 0 {
links += "\n" links += "\n"
@ -515,12 +531,12 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "") url.Fragment = s.genRemark(inbound, email, "", server.Name)
return url.String() return url.String()
} }
func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string { func (s *SubService) genTrojanLink(inbound *model.Inbound, email string, server *model.Server) string {
address := s.address address := server.Address
if inbound.Protocol != model.Trojan { if inbound.Protocol != model.Trojan {
return "" return ""
} }
@ -689,7 +705,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) url.Fragment = s.genRemark(inbound, email, ep["remark"].(string), server.Name)
if index > 0 { if index > 0 {
links += "\n" links += "\n"
@ -711,12 +727,12 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "") url.Fragment = s.genRemark(inbound, email, "", server.Name)
return url.String() return url.String()
} }
func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string, server *model.Server) string {
address := s.address address := server.Address
if inbound.Protocol != model.Shadowsocks { if inbound.Protocol != model.Shadowsocks {
return "" return ""
} }
@ -856,7 +872,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) url.Fragment = s.genRemark(inbound, email, ep["remark"].(string), server.Name)
if index > 0 { if index > 0 {
links += "\n" links += "\n"
@ -877,17 +893,18 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "") url.Fragment = s.genRemark(inbound, email, "", server.Name)
return url.String() return url.String()
} }
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string { func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string, serverName string) string {
separationChar := string(s.remarkModel[0]) separationChar := string(s.remarkModel[0])
orderChars := s.remarkModel[1:] orderChars := s.remarkModel[1:]
orders := map[byte]string{ orders := map[byte]string{
'i': "", 'i': "",
'e': "", 'e': "",
'o': "", 'o': "",
's': "",
} }
if len(email) > 0 { if len(email) > 0 {
orders['e'] = email orders['e'] = email
@ -898,6 +915,9 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
if len(extra) > 0 { if len(extra) > 0 {
orders['o'] = extra orders['o'] = extra
} }
if len(serverName) > 0 {
orders['s'] = serverName
}
var remark []string var remark []string
for i := 0; i < len(orderChars); i++ { for i := 0; i < len(orderChars); i++ {
@ -1110,7 +1130,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
return PageData{ return PageData{
Host: hostHeader, Host: hostHeader,
BasePath: "/", BasePath: "/", // kept as "/"; templates now use context base_path injected from router
SId: subId, SId: subId,
Download: download, Download: download,
Upload: upload, Upload: upload,
@ -1139,10 +1159,3 @@ func getHostFromXFH(s string) (string, error) {
} }
return s, nil return s, nil
} }
func parseInt64(s string) (int64, error) {
// handle potential quotes
s = strings.Trim(s, "\"'")
n, err := strconv.ParseInt(s, 10, 64)
return n, err
}

View file

@ -4,7 +4,12 @@
package sys package sys
import ( import (
"encoding/binary"
"fmt"
"sync"
"github.com/shirou/gopsutil/v4/net" "github.com/shirou/gopsutil/v4/net"
"golang.org/x/sys/unix"
) )
func GetTCPCount() (int, error) { func GetTCPCount() (int, error) {
@ -22,3 +27,69 @@ func GetUDPCount() (int, error) {
} }
return len(stats), nil return len(stats), nil
} }
// --- CPU Utilization (macOS native) ---
// sysctl kern.cp_time returns an array of 5 longs: user, nice, sys, idle, intr.
// We compute utilization deltas without cgo.
var (
cpuMu sync.Mutex
lastTotals [5]uint64
hasLastCPUT bool
)
func CPUPercentRaw() (float64, error) {
raw, err := unix.SysctlRaw("kern.cp_time")
if err != nil {
return 0, err
}
// Expect either 5*8 bytes (uint64) or 5*4 bytes (uint32)
var out [5]uint64
switch len(raw) {
case 5 * 8:
for i := 0; i < 5; i++ {
out[i] = binary.LittleEndian.Uint64(raw[i*8 : (i+1)*8])
}
case 5 * 4:
for i := 0; i < 5; i++ {
out[i] = uint64(binary.LittleEndian.Uint32(raw[i*4 : (i+1)*4]))
}
default:
return 0, fmt.Errorf("unexpected kern.cp_time size: %d", len(raw))
}
// user, nice, sys, idle, intr
user := out[0]
nice := out[1]
sysv := out[2]
idle := out[3]
intr := out[4]
cpuMu.Lock()
defer cpuMu.Unlock()
if !hasLastCPUT {
lastTotals = out
hasLastCPUT = true
return 0, nil
}
dUser := user - lastTotals[0]
dNice := nice - lastTotals[1]
dSys := sysv - lastTotals[2]
dIdle := idle - lastTotals[3]
dIntr := intr - lastTotals[4]
lastTotals = out
totald := dUser + dNice + dSys + dIdle + dIntr
if totald == 0 {
return 0, nil
}
busy := totald - dIdle
pct := float64(busy) / float64(totald) * 100.0
if pct > 100 {
pct = 100
}
return pct, nil
}

View file

@ -4,10 +4,14 @@
package sys package sys
import ( import (
"bufio"
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"os" "os"
"strconv"
"strings"
"sync"
) )
func getLinesNum(filename string) (int, error) { func getLinesNum(filename string) (int, error) {
@ -79,3 +83,99 @@ func safeGetLinesNum(path string) (int, error) {
} }
return getLinesNum(path) return getLinesNum(path)
} }
// --- CPU Utilization (Linux native) ---
var (
cpuMu sync.Mutex
lastTotal uint64
lastIdleAll uint64
hasLast bool
)
// CPUPercentRaw returns instantaneous total CPU utilization by reading /proc/stat.
// First call initializes and returns 0; subsequent calls return busy/total * 100.
func CPUPercentRaw() (float64, error) {
f, err := os.Open("/proc/stat")
if err != nil {
return 0, err
}
defer f.Close()
rd := bufio.NewReader(f)
line, err := rd.ReadString('\n')
if err != nil && err != io.EOF {
return 0, err
}
// Expect line like: cpu user nice system idle iowait irq softirq steal guest guest_nice
fields := strings.Fields(line)
if len(fields) < 5 || fields[0] != "cpu" {
return 0, fmt.Errorf("unexpected /proc/stat format")
}
var nums []uint64
for i := 1; i < len(fields); i++ {
v, err := strconv.ParseUint(fields[i], 10, 64)
if err != nil {
break
}
nums = append(nums, v)
}
if len(nums) < 4 { // need at least user,nice,system,idle
return 0, fmt.Errorf("insufficient cpu fields")
}
// Conform with standard Linux CPU accounting
var user, nice, system, idle, iowait, irq, softirq, steal uint64
user = nums[0]
if len(nums) > 1 {
nice = nums[1]
}
if len(nums) > 2 {
system = nums[2]
}
if len(nums) > 3 {
idle = nums[3]
}
if len(nums) > 4 {
iowait = nums[4]
}
if len(nums) > 5 {
irq = nums[5]
}
if len(nums) > 6 {
softirq = nums[6]
}
if len(nums) > 7 {
steal = nums[7]
}
idleAll := idle + iowait
nonIdle := user + nice + system + irq + softirq + steal
total := idleAll + nonIdle
cpuMu.Lock()
defer cpuMu.Unlock()
if !hasLast {
lastTotal = total
lastIdleAll = idleAll
hasLast = true
return 0, nil
}
totald := total - lastTotal
idled := idleAll - lastIdleAll
lastTotal = total
lastIdleAll = idleAll
if totald == 0 {
return 0, nil
}
busy := totald - idled
pct := float64(busy) / float64(totald) * 100.0
if pct > 100 {
pct = 100
}
return pct, nil
}

View file

@ -5,6 +5,9 @@ package sys
import ( import (
"errors" "errors"
"sync"
"syscall"
"unsafe"
"github.com/shirou/gopsutil/v4/net" "github.com/shirou/gopsutil/v4/net"
) )
@ -28,3 +31,81 @@ func GetTCPCount() (int, error) {
func GetUDPCount() (int, error) { func GetUDPCount() (int, error) {
return GetConnectionCount("udp") return GetConnectionCount("udp")
} }
// --- CPU Utilization (Windows native) ---
var (
modKernel32 = syscall.NewLazyDLL("kernel32.dll")
procGetSystemTimes = modKernel32.NewProc("GetSystemTimes")
cpuMu sync.Mutex
lastIdle uint64
lastKernel uint64
lastUser uint64
hasLast bool
)
type filetime struct {
LowDateTime uint32
HighDateTime uint32
}
func ftToUint64(ft filetime) uint64 {
return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
}
// CPUPercentRaw returns the instantaneous total CPU utilization percentage using
// Windows GetSystemTimes across all logical processors. The first call returns 0
// as it initializes the baseline. Subsequent calls compute deltas.
func CPUPercentRaw() (float64, error) {
var idleFT, kernelFT, userFT filetime
r1, _, e1 := procGetSystemTimes.Call(
uintptr(unsafe.Pointer(&idleFT)),
uintptr(unsafe.Pointer(&kernelFT)),
uintptr(unsafe.Pointer(&userFT)),
)
if r1 == 0 { // failure
if e1 != nil {
return 0, e1
}
return 0, syscall.GetLastError()
}
idle := ftToUint64(idleFT)
kernel := ftToUint64(kernelFT)
user := ftToUint64(userFT)
cpuMu.Lock()
defer cpuMu.Unlock()
if !hasLast {
lastIdle = idle
lastKernel = kernel
lastUser = user
hasLast = true
return 0, nil
}
idleDelta := idle - lastIdle
kernelDelta := kernel - lastKernel
userDelta := user - lastUser
// Update for next call
lastIdle = idle
lastKernel = kernel
lastUser = user
total := kernelDelta + userDelta
if total == 0 {
return 0, nil
}
// On Windows, kernel time includes idle time; busy = total - idle
busy := total - idleDelta
pct := float64(busy) / float64(total) * 100.0
// lower bound not needed; ratios of uint64 are non-negative
if pct > 100 {
pct = 100
}
return pct, nil
}

View file

@ -647,10 +647,6 @@ class Outbound extends CommonClass {
].includes(this.protocol); ].includes(this.protocol);
} }
hasVnext() {
return [Protocols.VMess, Protocols.VLESS].includes(this.protocol);
}
hasServers() { hasServers() {
return [Protocols.Trojan, Protocols.Shadowsocks, Protocols.Socks, Protocols.HTTP].includes(this.protocol); return [Protocols.Trojan, Protocols.Shadowsocks, Protocols.Socks, Protocols.HTTP].includes(this.protocol);
} }
@ -690,13 +686,22 @@ class Outbound extends CommonClass {
if (this.stream?.sockopt) if (this.stream?.sockopt)
stream = { sockopt: this.stream.sockopt.toJson() }; stream = { sockopt: this.stream.sockopt.toJson() };
} }
// For VMess/VLESS, emit settings as a flat object
let settingsOut = this.settings instanceof CommonClass ? this.settings.toJson() : this.settings;
// Remove undefined/null keys
if (settingsOut && typeof settingsOut === 'object') {
Object.keys(settingsOut).forEach(k => {
if (settingsOut[k] === undefined || settingsOut[k] === null) delete settingsOut[k];
});
}
return { return {
tag: this.tag == '' ? undefined : this.tag,
protocol: this.protocol, protocol: this.protocol,
settings: this.settings instanceof CommonClass ? this.settings.toJson() : this.settings, settings: settingsOut,
streamSettings: stream, // Only include tag, streamSettings, sendThrough, mux if present and not empty
sendThrough: this.sendThrough != "" ? this.sendThrough : undefined, ...(this.tag ? { tag: this.tag } : {}),
mux: this.mux?.enabled ? this.mux : undefined, ...(stream ? { streamSettings: stream } : {}),
...(this.sendThrough ? { sendThrough: this.sendThrough } : {}),
...(this.mux?.enabled ? { mux: this.mux } : {}),
}; };
} }
@ -908,7 +913,7 @@ Outbound.FreedomSettings = class extends CommonClass {
toJson() { toJson() {
return { return {
domainStrategy: ObjectUtil.isEmpty(this.domainStrategy) ? undefined : this.domainStrategy, domainStrategy: ObjectUtil.isEmpty(this.domainStrategy) ? undefined : this.domainStrategy,
redirect: ObjectUtil.isEmpty(this.redirect) ? undefined: this.redirect, redirect: ObjectUtil.isEmpty(this.redirect) ? undefined : this.redirect,
fragment: Object.keys(this.fragment).length === 0 ? undefined : this.fragment, fragment: Object.keys(this.fragment).length === 0 ? undefined : this.fragment,
noises: this.noises.length === 0 ? undefined : Outbound.FreedomSettings.Noise.toJsonArray(this.noises), noises: this.noises.length === 0 ? undefined : Outbound.FreedomSettings.Noise.toJsonArray(this.noises),
}; };
@ -1026,22 +1031,21 @@ Outbound.VmessSettings = class extends CommonClass {
} }
static fromJson(json = {}) { static fromJson(json = {}) {
if (ObjectUtil.isArrEmpty(json.vnext)) return new Outbound.VmessSettings(); if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VmessSettings();
return new Outbound.VmessSettings( return new Outbound.VmessSettings(
json.vnext[0].address, json.address,
json.vnext[0].port, json.port,
json.vnext[0].users[0].id, json.id,
json.vnext[0].users[0].security, json.security,
); );
} }
toJson() { toJson() {
return { return {
vnext: [{
address: this.address, address: this.address,
port: this.port, port: this.port,
users: [{ id: this.id, security: this.security }], id: this.id,
}], security: this.security,
}; };
} }
}; };
@ -1056,23 +1060,23 @@ Outbound.VLESSSettings = class extends CommonClass {
} }
static fromJson(json = {}) { static fromJson(json = {}) {
if (ObjectUtil.isArrEmpty(json.vnext)) return new Outbound.VLESSSettings(); if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings();
return new Outbound.VLESSSettings( return new Outbound.VLESSSettings(
json.vnext[0].address, json.address,
json.vnext[0].port, json.port,
json.vnext[0].users[0].id, json.id,
json.vnext[0].users[0].flow, json.flow,
json.vnext[0].users[0].encryption, json.encryption
); );
} }
toJson() { toJson() {
return { return {
vnext: [{
address: this.address, address: this.address,
port: this.port, port: this.port,
users: [{ id: this.id, flow: this.flow, encryption: this.encryption }], id: this.id,
}], flow: this.flow,
encryption: this.encryption,
}; };
} }
}; };

View file

@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"x-ui/database/model" "x-ui/database/model"
"x-ui/web/middleware"
"x-ui/web/service" "x-ui/web/service"
"x-ui/web/session" "x-ui/web/session"

View file

@ -0,0 +1,89 @@
package controller
import (
"strconv"
"x-ui/database/model"
"x-ui/web/service"
"github.com/gin-gonic/gin"
)
type MultiServerController struct {
multiServerService service.MultiServerService
}
func NewMultiServerController(g *gin.RouterGroup) *MultiServerController {
c := &MultiServerController{}
c.initRouter(g)
return c
}
func (c *MultiServerController) initRouter(g *gin.RouterGroup) {
g = g.Group("/server")
g.GET("/list", c.getServers)
g.POST("/add", c.addServer)
g.POST("/del/:id", c.delServer)
g.POST("/update/:id", c.updateServer)
}
func (c *MultiServerController) getServers(ctx *gin.Context) {
servers, err := c.multiServerService.GetServers()
if err != nil {
jsonMsg(ctx, "Failed to get servers", err)
return
}
jsonObj(ctx, servers, nil)
}
func (c *MultiServerController) addServer(ctx *gin.Context) {
server := &model.Server{}
err := ctx.ShouldBind(server)
if err != nil {
jsonMsg(ctx, "Invalid data", err)
return
}
err = c.multiServerService.AddServer(server)
if err != nil {
jsonMsg(ctx, "Failed to add server", err)
return
}
jsonMsg(ctx, "Server added successfully", nil)
}
func (c *MultiServerController) delServer(ctx *gin.Context) {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
jsonMsg(ctx, "Invalid ID", err)
return
}
err = c.multiServerService.DeleteServer(id)
if err != nil {
jsonMsg(ctx, "Failed to delete server", err)
return
}
jsonMsg(ctx, "Server deleted successfully", nil)
}
func (c *MultiServerController) updateServer(ctx *gin.Context) {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
jsonMsg(ctx, "Invalid ID", err)
return
}
server := &model.Server{
Id: id,
}
err = ctx.ShouldBind(server)
if err != nil {
jsonMsg(ctx, "Invalid data", err)
return
}
err = c.multiServerService.UpdateServer(server)
if err != nil {
jsonMsg(ctx, "Failed to update server", err)
return
}
jsonMsg(ctx, "Server updated successfully", nil)
}

View file

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"time" "time"
"x-ui/web/global" "x-ui/web/global"
@ -21,16 +22,13 @@ type ServerController struct {
settingService service.SettingService settingService service.SettingService
lastStatus *service.Status lastStatus *service.Status
lastGetStatusTime time.Time
lastVersions []string lastVersions []string
lastGetVersionsTime time.Time lastGetVersionsTime int64 // unix seconds
} }
func NewServerController(g *gin.RouterGroup) *ServerController { func NewServerController(g *gin.RouterGroup) *ServerController {
a := &ServerController{ a := &ServerController{}
lastGetStatusTime: time.Now(),
}
a.initRouter(g) a.initRouter(g)
a.startTask() a.startTask()
return a return a
@ -39,6 +37,7 @@ func NewServerController(g *gin.RouterGroup) *ServerController {
func (a *ServerController) initRouter(g *gin.RouterGroup) { func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/status", a.status) g.GET("/status", a.status)
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
g.GET("/getXrayVersion", a.getXrayVersion) g.GET("/getXrayVersion", a.getXrayVersion)
g.GET("/getConfigJson", a.getConfigJson) g.GET("/getConfigJson", a.getConfigJson)
g.GET("/getDb", a.getDb) g.GET("/getDb", a.getDb)
@ -61,29 +60,50 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
func (a *ServerController) refreshStatus() { func (a *ServerController) refreshStatus() {
a.lastStatus = a.serverService.GetStatus(a.lastStatus) a.lastStatus = a.serverService.GetStatus(a.lastStatus)
// collect cpu history when status is fresh
if a.lastStatus != nil {
a.serverService.AppendCpuSample(time.Now(), a.lastStatus.Cpu)
}
} }
func (a *ServerController) startTask() { func (a *ServerController) startTask() {
webServer := global.GetWebServer() webServer := global.GetWebServer()
c := webServer.GetCron() c := webServer.GetCron()
c.AddFunc("@every 2s", func() { c.AddFunc("@every 2s", func() {
now := time.Now() // Always refresh to keep CPU history collected continuously.
if now.Sub(a.lastGetStatusTime) > time.Minute*3 { // Sampling is lightweight and capped to ~6 hours in memory.
return
}
a.refreshStatus() a.refreshStatus()
}) })
} }
func (a *ServerController) status(c *gin.Context) { func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) }
a.lastGetStatusTime = time.Now()
jsonObj(c, a.lastStatus, nil) func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
bucketStr := c.Param("bucket")
bucket, err := strconv.Atoi(bucketStr)
if err != nil || bucket <= 0 {
jsonMsg(c, "invalid bucket", fmt.Errorf("bad bucket"))
return
}
allowed := map[int]bool{
2: true, // Real-time view
30: true, // 30s intervals
60: true, // 1m intervals
120: true, // 2m intervals
180: true, // 3m intervals
300: true, // 5m intervals
}
if !allowed[bucket] {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return
}
points := a.serverService.AggregateCpuHistory(bucket, 60)
jsonObj(c, points, nil)
} }
func (a *ServerController) getXrayVersion(c *gin.Context) { func (a *ServerController) getXrayVersion(c *gin.Context) {
now := time.Now() now := time.Now().Unix()
if now.Sub(a.lastGetVersionsTime) <= time.Minute { if now-a.lastGetVersionsTime <= 60 { // 1 minute cache
jsonObj(c, a.lastVersions, nil) jsonObj(c, a.lastVersions, nil)
return return
} }
@ -95,7 +115,7 @@ func (a *ServerController) getXrayVersion(c *gin.Context) {
} }
a.lastVersions = versions a.lastVersions = versions
a.lastGetVersionsTime = time.Now() a.lastGetVersionsTime = now
jsonObj(c, versions, nil) jsonObj(c, versions, nil)
} }
@ -113,7 +133,6 @@ func (a *ServerController) updateGeofile(c *gin.Context) {
} }
func (a *ServerController) stopXrayService(c *gin.Context) { func (a *ServerController) stopXrayService(c *gin.Context) {
a.lastGetStatusTime = time.Now()
err := a.serverService.StopXrayService() err := a.serverService.StopXrayService()
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err) jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err)
@ -229,9 +248,7 @@ func (a *ServerController) importDB(c *gin.Context) {
defer file.Close() defer file.Close()
// Always restart Xray before return // Always restart Xray before return
defer a.serverService.RestartXrayService() defer a.serverService.RestartXrayService()
defer func() { // lastGetStatusTime removed; no longer needed
a.lastGetStatusTime = time.Now()
}()
// Import it // Import it
err = a.serverService.ImportDB(file) err = a.serverService.ImportDB(file)
if err != nil { if err != nil {

View file

@ -23,13 +23,13 @@ func NewXraySettingController(g *gin.RouterGroup) *XraySettingController {
func (a *XraySettingController) initRouter(g *gin.RouterGroup) { func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
g = g.Group("/xray") g = g.Group("/xray")
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
g.GET("/getXrayResult", a.getXrayResult)
g.POST("/", a.getXraySetting) g.POST("/", a.getXraySetting)
g.POST("/update", a.updateSetting)
g.GET("/getXrayResult", a.getXrayResult)
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.POST("/warp/:action", a.warp) g.POST("/warp/:action", a.warp)
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic) g.POST("/update", a.updateSetting)
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic) g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
} }

View file

@ -25,6 +25,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index) g.GET("/", a.index)
g.GET("/inbounds", a.inbounds) g.GET("/inbounds", a.inbounds)
g.GET("/servers", a.servers)
g.GET("/settings", a.settings) g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings) g.GET("/xray", a.xraySettings)
@ -49,3 +50,7 @@ func (a *XUIController) settings(c *gin.Context) {
func (a *XUIController) xraySettings(c *gin.Context) { func (a *XUIController) xraySettings(c *gin.Context) {
html(c, "xray.html", "pages.xray.title", nil) html(c, "xray.html", "pages.xray.title", nil)
} }
func (a *XUIController) servers(c *gin.Context) {
html(c, "servers.html", "Servers", nil)
}

View file

@ -37,7 +37,7 @@
<template slot="content" > <template slot="content" >
{{ i18n "lastOnline" }}: [[ formatLastOnline(client.email) ]] {{ i18n "lastOnline" }}: [[ formatLastOnline(client.email) ]]
</template> </template>
<template v-if="client.enable && isClientOnline(client.email)"> <template v-if="client.enable && isClientOnline(client.email) && !isClientDepleted">
<a-tag color="green">{{ i18n "online" }}</a-tag> <a-tag color="green">{{ i18n "online" }}</a-tag>
</template> </template>
<template v-else> <template v-else>
@ -49,9 +49,9 @@
<a-space direction="horizontal" :size="2"> <a-space direction="horizontal" :size="2">
<a-tooltip> <a-tooltip>
<template slot="title"> <template slot="title">
<template v-if="!isClientEnabled(record, client.email)">{{ i18n "depleted" }}</template> <template v-if="isClientDepleted">{{ i18n "depleted" }}</template>
<template v-else-if="!client.enable">{{ i18n "disabled" }}</template> <template v-if="!isClientDepleted && !client.enable">{{ i18n "disabled" }}</template>
<template v-else-if="client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template> <template v-if="!isClientDepleted && client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template>
</template> </template>
<a-badge :class="isClientOnline(client.email)? 'online-animation' : ''" :color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge> <a-badge :class="isClientOnline(client.email)? 'online-animation' : ''" :color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge>
</a-tooltip> </a-tooltip>

View file

@ -54,6 +54,11 @@
icon: 'user', icon: 'user',
title: '{{ i18n "menu.inbounds"}}' title: '{{ i18n "menu.inbounds"}}'
}, },
{
key: '{{ .base_path }}panel/servers',
icon: 'cloud-server',
title: 'Servers'
},
{ {
key: '{{ .base_path }}panel/settings', key: '{{ .base_path }}panel/settings',
icon: 'setting', icon: 'setting',

View file

@ -210,7 +210,7 @@
</a-form-item> </a-form-item>
</template> </template>
<!-- Vnext (vless/vmess) settings --> <!-- VLESS/VMess user settings -->
<template v-if="[Protocols.VMess, Protocols.VLESS].includes(outbound.protocol)"> <template v-if="[Protocols.VMess, Protocols.VLESS].includes(outbound.protocol)">
<a-form-item label='ID'> <a-form-item label='ID'>
<a-input v-model.trim="outbound.settings.id"></a-input> <a-input v-model.trim="outbound.settings.id"></a-input>

View file

@ -22,10 +22,10 @@
<a-input-number v-model.number="inbound.stream.reality.maxTimediff" :min="0"></a-input-number> <a-input-number v-model.number="inbound.stream.reality.maxTimediff" :min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='Min Client Ver'> <a-form-item label='Min Client Ver'>
<a-input v-model.trim="inbound.stream.reality.minClientVer"></a-input> <a-input v-model.trim="inbound.stream.reality.minClientVer" placeholder='25.9.11'></a-input>
</a-form-item> </a-form-item>
<a-form-item label='Max Client Ver'> <a-form-item label='Max Client Ver'>
<a-input v-model.trim="inbound.stream.reality.maxClientVer"></a-input> <a-input v-model.trim="inbound.stream.reality.maxClientVer" placeholder='25.9.11'></a-input>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<template slot="label"> <template slot="label">

View file

@ -9,15 +9,13 @@
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'> <a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
<transition name="list" appear> <transition name="list" appear>
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }" <a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
message='{{ i18n "secAlertTitle" }}' message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
color="red"
description='{{ i18n "secAlertSsl" }}'
show-icon closable>
</a-alert> </a-alert>
</transition> </transition>
<transition name="list" appear> <transition name="list" appear>
<a-row v-if="!loadingStates.fetched"> <a-row v-if="!loadingStates.fetched">
<a-card :style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }"> <a-card
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
<a-spin tip='{{ i18n "loading" }}'></a-spin> <a-spin tip='{{ i18n "loading" }}'></a-spin>
</a-card> </a-card>
</a-row> </a-row>
@ -26,40 +24,47 @@
<a-card size="small" :style="{ padding: '16px' }" hoverable> <a-card size="small" :style="{ padding: '16px' }" hoverable>
<a-row> <a-row>
<a-col :sm="12" :md="5"> <a-col :sm="12" :md="5">
<a-custom-statistic title='{{ i18n "pages.inbounds.totalDownUp" }}' :value="`${SizeFormatter.sizeFormat(total.up)} / ${SizeFormatter.sizeFormat(total.down)}`"> <a-custom-statistic title='{{ i18n "pages.inbounds.totalDownUp" }}'
:value="`${SizeFormatter.sizeFormat(total.up)} / ${SizeFormatter.sizeFormat(total.down)}`">
<template #prefix> <template #prefix>
<a-icon type="swap"></a-icon> <a-icon type="swap"></a-icon>
</template> </template>
</a-custom-statistic> </a-custom-statistic>
</a-col> </a-col>
<a-col :sm="12" :md="5"> <a-col :sm="12" :md="5">
<a-custom-statistic title='{{ i18n "pages.inbounds.totalUsage" }}' :value="SizeFormatter.sizeFormat(total.up + total.down)" :style="{ marginTop: isMobile ? '10px' : 0 }"> <a-custom-statistic title='{{ i18n "pages.inbounds.totalUsage" }}'
:value="SizeFormatter.sizeFormat(total.up + total.down)"
:style="{ marginTop: isMobile ? '10px' : 0 }">
<template #prefix> <template #prefix>
<a-icon type="pie-chart"></a-icon> <a-icon type="pie-chart"></a-icon>
</template> </template>
</a-custom-statistic> </a-custom-statistic>
</a-col> </a-col>
<a-col :sm="12" :md="5"> <a-col :sm="12" :md="5">
<a-custom-statistic title='{{ i18n "pages.inbounds.allTimeTrafficUsage" }}' :value="SizeFormatter.sizeFormat(total.allTime)" :style="{ marginTop: isMobile ? '10px' : 0 }"> <a-custom-statistic title='{{ i18n "pages.inbounds.allTimeTrafficUsage" }}'
:value="SizeFormatter.sizeFormat(total.allTime)" :style="{ marginTop: isMobile ? '10px' : 0 }">
<template #prefix> <template #prefix>
<a-icon type="history"></a-icon> <a-icon type="history"></a-icon>
</template> </template>
</a-custom-statistic> </a-custom-statistic>
</a-col> </a-col>
<a-col :sm="12" :md="5"> <a-col :sm="12" :md="5">
<a-custom-statistic title='{{ i18n "pages.inbounds.inboundCount" }}' :value="dbInbounds.length" :style="{ marginTop: isMobile ? '10px' : 0 }"> <a-custom-statistic title='{{ i18n "pages.inbounds.inboundCount" }}' :value="dbInbounds.length"
:style="{ marginTop: isMobile ? '10px' : 0 }">
<template #prefix> <template #prefix>
<a-icon type="bars"></a-icon> <a-icon type="bars"></a-icon>
</template> </template>
</a-custom-statistic> </a-custom-statistic>
</a-col> </a-col>
<a-col :sm="12" :md="4"> <a-col :sm="12" :md="4">
<a-custom-statistic title='{{ i18n "clients" }}' value=" " :style="{ marginTop: isMobile ? '10px' : 0 }"> <a-custom-statistic title='{{ i18n "clients" }}' value=" "
:style="{ marginTop: isMobile ? '10px' : 0 }">
<template #prefix> <template #prefix>
<a-space direction="horizontal"> <a-space direction="horizontal">
<a-icon type="team"></a-icon> <a-icon type="team"></a-icon>
<div> <div>
<a-back-top :target="() => document.getElementById('content-layout')" visibility-height="200"></a-back-top> <a-back-top :target="() => document.getElementById('content-layout')"
visibility-height="200"></a-back-top>
<a-tag color="green">[[ total.clients ]]</a-tag> <a-tag color="green">[[ total.clients ]]</a-tag>
<a-popover title='{{ i18n "disabled" }}' :overlay-class-name="themeSwitcher.currentTheme"> <a-popover title='{{ i18n "disabled" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content"> <template slot="content">
@ -73,7 +78,8 @@
</template> </template>
<a-tag color="red" v-if="total.depleted.length">[[ total.depleted.length ]]</a-tag> <a-tag color="red" v-if="total.depleted.length">[[ total.depleted.length ]]</a-tag>
</a-popover> </a-popover>
<a-popover title='{{ i18n "depletingSoon" }}' :overlay-class-name="themeSwitcher.currentTheme"> <a-popover title='{{ i18n "depletingSoon" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content"> <template slot="content">
<div v-for="clientEmail in total.expiring"><span>[[ clientEmail ]]</span></div> <div v-for="clientEmail in total.expiring"><span>[[ clientEmail ]]</span></div>
</template> </template>
@ -146,11 +152,8 @@
<template #content> <template #content>
<a-space direction="vertical"> <a-space direction="vertical">
<span>{{ i18n "pages.inbounds.autoRefreshInterval" }}</span> <span>{{ i18n "pages.inbounds.autoRefreshInterval" }}</span>
<a-select v-model="refreshInterval" <a-select v-model="refreshInterval" :disabled="!isRefreshEnabled" :style="{ width: '100%' }"
:disabled="!isRefreshEnabled" @change="changeRefreshInterval" :dropdown-class-name="themeSwitcher.currentTheme">
:style="{ width: '100%' }"
@change="changeRefreshInterval"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in [5,10,30,60]" :value="key*1000">[[ key ]]s</a-select-option> <a-select-option v-for="key in [5,10,30,60]" :value="key*1000">[[ key ]]s</a-select-option>
</a-select> </a-select>
</a-space> </a-space>
@ -167,8 +170,10 @@
<a-icon slot="checkedChildren" type="search"></a-icon> <a-icon slot="checkedChildren" type="search"></a-icon>
<a-icon slot="unCheckedChildren" type="filter"></a-icon> <a-icon slot="unCheckedChildren" type="filter"></a-icon>
</a-switch> </a-switch>
<a-input v-if="!enableFilter" v-model.lazy="searchKey" placeholder='{{ i18n "search" }}' autofocus :style="{ maxWidth: '300px' }" :size="isMobile ? 'small' : ''"></a-input> <a-input v-if="!enableFilter" v-model.lazy="searchKey" placeholder='{{ i18n "search" }}' autofocus
<a-radio-group v-if="enableFilter" v-model="filterBy" @change="filterInbounds" button-style="solid" :size="isMobile ? 'small' : ''"> :style="{ maxWidth: '300px' }" :size="isMobile ? 'small' : ''"></a-input>
<a-radio-group v-if="enableFilter" v-model="filterBy" @change="filterInbounds" button-style="solid"
:size="isMobile ? 'small' : ''">
<a-radio-button value="">{{ i18n "none" }}</a-radio-button> <a-radio-button value="">{{ i18n "none" }}</a-radio-button>
<a-radio-button value="deactive">{{ i18n "disabled" }}</a-radio-button> <a-radio-button value="deactive">{{ i18n "disabled" }}</a-radio-button>
<a-radio-button value="depleted">{{ i18n "depleted" }}</a-radio-button> <a-radio-button value="depleted">{{ i18n "depleted" }}</a-radio-button>
@ -177,25 +182,24 @@
</a-radio-group> </a-radio-group>
</div> </div>
<a-table :columns="isMobile ? mobileColumns : columns" :row-key="dbInbound => dbInbound.id" <a-table :columns="isMobile ? mobileColumns : columns" :row-key="dbInbound => dbInbound.id"
:data-source="searchedInbounds" :data-source="searchedInbounds" :scroll="isMobile ? {} : { x: 1000 }"
:scroll="isMobile ? {} : { x: 1000 }" :pagination=pagination(searchedInbounds) :expand-icon-as-cell="false" :expand-row-by-click="false"
:pagination=pagination(searchedInbounds) :expand-icon-column-index="0" :indent-size="0"
:expand-icon-as-cell="false"
:expand-row-by-click="false"
:expand-icon-column-index="0"
:indent-size="0"
:row-class-name="dbInbound => (dbInbound.isMultiUser() ? '' : 'hideExpandIcon')" :row-class-name="dbInbound => (dbInbound.isMultiUser() ? '' : 'hideExpandIcon')"
:style="{ marginTop: '10px' }" :style="{ marginTop: '10px' }"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'> :locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'>
<template slot="action" slot-scope="text, dbInbound"> <template slot="action" slot-scope="text, dbInbound">
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more" :style="{ fontSize: '20px', textDecoration: 'solid' }"></a-icon> <a-icon @click="e => e.preventDefault()" type="more"
<a-menu slot="overlay" @click="a => clickAction(a, dbInbound)" :theme="themeSwitcher.currentTheme"> :style="{ fontSize: '20px', textDecoration: 'solid' }"></a-icon>
<a-menu slot="overlay" @click="a => clickAction(a, dbInbound)"
:theme="themeSwitcher.currentTheme">
<a-menu-item key="edit"> <a-menu-item key="edit">
<a-icon type="edit"></a-icon> <a-icon type="edit"></a-icon>
{{ i18n "edit" }} {{ i18n "edit" }}
</a-menu-item> </a-menu-item>
<a-menu-item key="qrcode" v-if="(dbInbound.isSS && !dbInbound.toInbound().isSSMultiUser) || dbInbound.isWireguard"> <a-menu-item key="qrcode"
v-if="(dbInbound.isSS && !dbInbound.toInbound().isSSMultiUser) || dbInbound.isWireguard">
<a-icon type="qrcode"></a-icon> <a-icon type="qrcode"></a-icon>
{{ i18n "qrCode" }} {{ i18n "qrCode" }}
</a-menu-item> </a-menu-item>
@ -247,7 +251,8 @@
</span> </span>
</a-menu-item> </a-menu-item>
<a-menu-item v-if="isMobile"> <a-menu-item v-if="isMobile">
<a-switch size="small" v-model="dbInbound.enable" @change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch> <a-switch size="small" v-model="dbInbound.enable"
@change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
{{ i18n "pages.inbounds.enable" }} {{ i18n "pages.inbounds.enable" }}
</a-menu-item> </a-menu-item>
</a-menu> </a-menu>
@ -257,8 +262,10 @@
<a-tag :style="{ margin: '0' }" color="purple">[[ dbInbound.protocol ]]</a-tag> <a-tag :style="{ margin: '0' }" color="purple">[[ dbInbound.protocol ]]</a-tag>
<template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS"> <template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
<a-tag :style="{ margin: '0' }" color="green">[[ dbInbound.toInbound().stream.network ]]</a-tag> <a-tag :style="{ margin: '0' }" color="green">[[ dbInbound.toInbound().stream.network ]]</a-tag>
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isTls" color="blue">TLS</a-tag> <a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isTls"
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isReality" color="blue">Reality</a-tag> color="blue">TLS</a-tag>
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isReality"
color="blue">Reality</a-tag>
</template> </template>
</template> </template>
<template slot="clients" slot-scope="text, dbInbound"> <template slot="clients" slot-scope="text, dbInbound">
@ -266,59 +273,75 @@
<a-tag :style="{ margin: '0' }" color="green">[[ clientCount[dbInbound.id].clients ]]</a-tag> <a-tag :style="{ margin: '0' }" color="green">[[ clientCount[dbInbound.id].clients ]]</a-tag>
<a-popover title='{{ i18n "disabled" }}' :overlay-class-name="themeSwitcher.currentTheme"> <a-popover title='{{ i18n "disabled" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content"> <template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].deactive" :key="clientEmail" class="client-popup-item"> <div v-for="clientEmail in clientCount[dbInbound.id].deactive" :key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span> <span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme"> <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title> <template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]] [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template> </template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon> <a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip> </a-tooltip>
</div> </div>
</template> </template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" v-if="clientCount[dbInbound.id].deactive.length">[[ clientCount[dbInbound.id].deactive.length ]]</a-tag> <a-tag :style="{ margin: '0', padding: '0 2px' }"
v-if="clientCount[dbInbound.id].deactive.length">[[
clientCount[dbInbound.id].deactive.length ]]</a-tag>
</a-popover> </a-popover>
<a-popover title='{{ i18n "depleted" }}' :overlay-class-name="themeSwitcher.currentTheme"> <a-popover title='{{ i18n "depleted" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content"> <template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].depleted" :key="clientEmail" class="client-popup-item"> <div v-for="clientEmail in clientCount[dbInbound.id].depleted" :key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span> <span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme"> <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title> <template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]] [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template> </template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon> <a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip> </a-tooltip>
</div> </div>
</template> </template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" color="red" v-if="clientCount[dbInbound.id].depleted.length">[[ clientCount[dbInbound.id].depleted.length ]]</a-tag> <a-tag :style="{ margin: '0', padding: '0 2px' }" color="red"
v-if="clientCount[dbInbound.id].depleted.length">[[
clientCount[dbInbound.id].depleted.length ]]</a-tag>
</a-popover> </a-popover>
<a-popover title='{{ i18n "depletingSoon" }}' :overlay-class-name="themeSwitcher.currentTheme"> <a-popover title='{{ i18n "depletingSoon" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content"> <template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].expiring" :key="clientEmail" class="client-popup-item"> <div v-for="clientEmail in clientCount[dbInbound.id].expiring" :key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span> <span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme"> <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title> <template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]] [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template> </template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon> <a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip> </a-tooltip>
</div> </div>
</template> </template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" color="orange" v-if="clientCount[dbInbound.id].expiring.length">[[ clientCount[dbInbound.id].expiring.length ]]</a-tag> <a-tag :style="{ margin: '0', padding: '0 2px' }" color="orange"
v-if="clientCount[dbInbound.id].expiring.length">[[
clientCount[dbInbound.id].expiring.length ]]</a-tag>
</a-popover> </a-popover>
<a-popover title='{{ i18n "online" }}' :overlay-class-name="themeSwitcher.currentTheme"> <a-popover title='{{ i18n "online" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content"> <template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].online" :key="clientEmail" class="client-popup-item"> <div v-for="clientEmail in clientCount[dbInbound.id].online" :key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span> <span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme"> <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title> <template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]] [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template> </template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon> <a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip> </a-tooltip>
</div> </div>
</template> </template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" color="blue" v-if="clientCount[dbInbound.id].online.length">[[ clientCount[dbInbound.id].online.length ]]</a-tag> <a-tag :style="{ margin: '0', padding: '0 2px' }" color="blue"
v-if="clientCount[dbInbound.id].online.length">[[ clientCount[dbInbound.id].online.length
]]</a-tag>
</a-popover> </a-popover>
</template> </template>
</template> </template>
@ -336,14 +359,17 @@
</tr> </tr>
</table> </table>
</template> </template>
<a-tag :color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)"> <a-tag
:color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] / [[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] /
<template v-if="dbInbound.total > 0"> <template v-if="dbInbound.total > 0">
[[ SizeFormatter.sizeFormat(dbInbound.total) ]] [[ SizeFormatter.sizeFormat(dbInbound.total) ]]
</template> </template>
<template v-else> <template v-else>
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor"> <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path> <path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
fill="currentColor"></path>
</svg> </svg>
</template> </template>
</a-tag> </a-tag>
@ -353,7 +379,8 @@
<a-tag>[[ SizeFormatter.sizeFormat(dbInbound.allTime || 0) ]]</a-tag> <a-tag>[[ SizeFormatter.sizeFormat(dbInbound.allTime || 0) ]]</a-tag>
</template> </template>
<template slot="enable" slot-scope="text, dbInbound"> <template slot="enable" slot-scope="text, dbInbound">
<a-switch v-model="dbInbound.enable" @change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch> <a-switch v-model="dbInbound.enable"
@change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
</template> </template>
<template slot="expiryTime" slot-scope="text, dbInbound"> <template slot="expiryTime" slot-scope="text, dbInbound">
<a-popover v-if="dbInbound.expiryTime > 0" :overlay-class-name="themeSwitcher.currentTheme"> <a-popover v-if="dbInbound.expiryTime > 0" :overlay-class-name="themeSwitcher.currentTheme">
@ -363,28 +390,36 @@
<template v-else slot="content"> <template v-else slot="content">
[[ DateUtil.convertToJalalian(moment(dbInbound.expiryTime)) ]] [[ DateUtil.convertToJalalian(moment(dbInbound.expiryTime)) ]]
</template> </template>
<a-tag :style="{ minWidth: '50px' }" :color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, dbInbound._expiryTime)"> <a-tag :style="{ minWidth: '50px' }"
:color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, dbInbound._expiryTime)">
[[ remainedDays(dbInbound._expiryTime) ]] [[ remainedDays(dbInbound._expiryTime) ]]
</a-tag> </a-tag>
</a-popover> </a-popover>
<a-tag v-else color="purple" class="infinite-tag"> <a-tag v-else color="purple" class="infinite-tag">
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor"> <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path> <path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
fill="currentColor"></path>
</svg> </svg>
</a-tag> </a-tag>
</template> </template>
<template slot="info" slot-scope="text, dbInbound"> <template slot="info" slot-scope="text, dbInbound">
<a-popover placement="bottomRight" :overlay-class-name="themeSwitcher.currentTheme" trigger="click"> <a-popover placement="bottomRight" :overlay-class-name="themeSwitcher.currentTheme"
trigger="click">
<template slot="content"> <template slot="content">
<table cellpadding="2"> <table cellpadding="2">
<tr> <tr>
<td>{{ i18n "pages.inbounds.protocol" }}</td> <td>{{ i18n "pages.inbounds.protocol" }}</td>
<td> <td>
<a-tag :style="{ margin: '0' }" color="purple">[[ dbInbound.protocol ]]</a-tag> <a-tag :style="{ margin: '0' }" color="purple">[[ dbInbound.protocol ]]</a-tag>
<template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS"> <template
<a-tag :style="{ margin: '0' }" color="blue">[[ dbInbound.toInbound().stream.network ]]</a-tag> v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isTls" color="green">tls</a-tag> <a-tag :style="{ margin: '0' }" color="blue">[[ dbInbound.toInbound().stream.network
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isReality" color="green">reality</a-tag> ]]</a-tag>
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isTls"
color="green">tls</a-tag>
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isReality"
color="green">reality</a-tag>
</template> </template>
</td> </td>
</tr> </tr>
@ -395,62 +430,82 @@
<tr v-if="clientCount[dbInbound.id]"> <tr v-if="clientCount[dbInbound.id]">
<td>{{ i18n "clients" }}</td> <td>{{ i18n "clients" }}</td>
<td> <td>
<a-tag :style="{ margin: '0' }" color="blue">[[ clientCount[dbInbound.id].clients ]]</a-tag> <a-tag :style="{ margin: '0' }" color="blue">[[ clientCount[dbInbound.id].clients
<a-popover title='{{ i18n "disabled" }}' :overlay-class-name="themeSwitcher.currentTheme"> ]]</a-tag>
<a-popover title='{{ i18n "disabled" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content"> <template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].deactive" :key="clientEmail" class="client-popup-item"> <div v-for="clientEmail in clientCount[dbInbound.id].deactive" :key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span> <span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme"> <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title> <template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]] [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template> </template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon> <a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip> </a-tooltip>
</div> </div>
</template> </template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" v-if="clientCount[dbInbound.id].deactive.length">[[ clientCount[dbInbound.id].deactive.length ]]</a-tag> <a-tag :style="{ margin: '0', padding: '0 2px' }"
v-if="clientCount[dbInbound.id].deactive.length">[[
clientCount[dbInbound.id].deactive.length ]]</a-tag>
</a-popover> </a-popover>
<a-popover title='{{ i18n "depleted" }}' :overlay-class-name="themeSwitcher.currentTheme"> <a-popover title='{{ i18n "depleted" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content"> <template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].depleted" :key="clientEmail" class="client-popup-item"> <div v-for="clientEmail in clientCount[dbInbound.id].depleted" :key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span> <span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme"> <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title> <template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]] [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template> </template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon> <a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip> </a-tooltip>
</div> </div>
</template> </template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" color="red" v-if="clientCount[dbInbound.id].depleted.length">[[ clientCount[dbInbound.id].depleted.length ]]</a-tag> <a-tag :style="{ margin: '0', padding: '0 2px' }" color="red"
v-if="clientCount[dbInbound.id].depleted.length">[[
clientCount[dbInbound.id].depleted.length ]]</a-tag>
</a-popover> </a-popover>
<a-popover title='{{ i18n "depletingSoon" }}' :overlay-class-name="themeSwitcher.currentTheme"> <a-popover title='{{ i18n "depletingSoon" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content"> <template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].expiring" :key="clientEmail" class="client-popup-item"> <div v-for="clientEmail in clientCount[dbInbound.id].expiring" :key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span> <span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme"> <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title> <template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]] [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template> </template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon> <a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip> </a-tooltip>
</div> </div>
</template> </template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" color="orange" v-if="clientCount[dbInbound.id].expiring.length">[[ clientCount[dbInbound.id].expiring.length ]]</a-tag> <a-tag :style="{ margin: '0', padding: '0 2px' }" color="orange"
v-if="clientCount[dbInbound.id].expiring.length">[[
clientCount[dbInbound.id].expiring.length ]]</a-tag>
</a-popover> </a-popover>
<a-popover title='{{ i18n "online" }}' :overlay-class-name="themeSwitcher.currentTheme"> <a-popover title='{{ i18n "online" }}' :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content"> <template slot="content">
<div v-for="clientEmail in clientCount[dbInbound.id].online" :key="clientEmail" class="client-popup-item"> <div v-for="clientEmail in clientCount[dbInbound.id].online" :key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span> <span>[[ clientEmail ]]</span>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme"> <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template #title> <template #title>
[[ clientCount[dbInbound.id].comments.get(clientEmail) ]] [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
</template> </template>
<a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon> <a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip> </a-tooltip>
</div> </div>
</template> </template>
<a-tag :style="{ margin: '0', padding: '0 2px' }" color="green" v-if="clientCount[dbInbound.id].online.length">[[ clientCount[dbInbound.id].online.length ]]</a-tag> <a-tag :style="{ margin: '0', padding: '0 2px' }" color="green"
v-if="clientCount[dbInbound.id].online.length">[[
clientCount[dbInbound.id].online.length ]]</a-tag>
</a-popover> </a-popover>
</td> </td>
</tr> </tr>
@ -464,20 +519,25 @@
<td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td> <td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td>
<td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td> <td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td>
</tr> </tr>
<tr v-if="dbInbound.total > 0 && dbInbound.up + dbInbound.down < dbInbound.total"> <tr
v-if="dbInbound.total > 0 && dbInbound.up + dbInbound.down < dbInbound.total">
<td>{{ i18n "remained" }}</td> <td>{{ i18n "remained" }}</td>
<td>[[ SizeFormatter.sizeFormat(dbInbound.total - dbInbound.up - dbInbound.down) ]]</td> <td>[[ SizeFormatter.sizeFormat(dbInbound.total - dbInbound.up - dbInbound.down)
]]</td>
</tr> </tr>
</table> </table>
</template> </template>
<a-tag :color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)"> <a-tag
:color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] / [[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] /
<template v-if="dbInbound.total > 0"> <template v-if="dbInbound.total > 0">
[[ SizeFormatter.sizeFormat(dbInbound.total) ]] [[ SizeFormatter.sizeFormat(dbInbound.total) ]]
</template> </template>
<template v-else> <template v-else>
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor"> <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path> <path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
fill="currentColor"></path>
</svg> </svg>
</template> </template>
</a-tag> </a-tag>
@ -487,8 +547,8 @@
<tr> <tr>
<td>{{ i18n "pages.inbounds.expireDate" }}</td> <td>{{ i18n "pages.inbounds.expireDate" }}</td>
<td> <td>
<a-tag :style="{ minWidth: '50px', textAlign: 'center' }" v-if="dbInbound.expiryTime > 0" <a-tag :style="{ minWidth: '50px', textAlign: 'center' }"
:color="dbInbound.isExpiry? 'red': 'blue'"> v-if="dbInbound.expiryTime > 0" :color="dbInbound.isExpiry? 'red': 'blue'">
<template v-if="app.datepicker === 'gregorian'"> <template v-if="app.datepicker === 'gregorian'">
[[ DateUtil.formatMillis(dbInbound.expiryTime) ]] [[ DateUtil.formatMillis(dbInbound.expiryTime) ]]
</template> </template>
@ -498,7 +558,9 @@
</a-tag> </a-tag>
<a-tag v-else :style="{ textAlign: 'center' }" color="purple" class="infinite-tag"> <a-tag v-else :style="{ textAlign: 'center' }" color="purple" class="infinite-tag">
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor"> <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path> <path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
fill="currentColor"></path>
</svg> </svg>
</a-tag> </a-tag>
</td> </td>
@ -506,13 +568,15 @@
<tr> <tr>
<td>{{ i18n "pages.inbounds.periodicTrafficResetTitle" }}</td> <td>{{ i18n "pages.inbounds.periodicTrafficResetTitle" }}</td>
<td> <td>
<a-tag color="blue">[[ i18n("pages.inbounds.periodicTrafficReset." + dbInbound.trafficReset) ]]</a-tag> <a-tag color="blue">[[ i18n("pages.inbounds.periodicTrafficReset." +
dbInbound.trafficReset) ]]</a-tag>
</td> </td>
</tr> </tr>
</table> </table>
</template> </template>
<a-badge> <a-badge>
<a-icon v-if="!dbInbound.enable" slot="count" type="pause-circle" :style="{ color: themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc' }"></a-icon> <a-icon v-if="!dbInbound.enable" slot="count" type="pause-circle"
:style="{ color: themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc' }"></a-icon>
<a-button shape="round" size="small" :style="{ fontSize: '14px', padding: '0 10px' }"> <a-button shape="round" size="small" :style="{ fontSize: '14px', padding: '0 10px' }">
<a-icon type="info"></a-icon> <a-icon type="info"></a-icon>
</a-button> </a-button>
@ -520,11 +584,8 @@
</a-popover> </a-popover>
</template> </template>
<template slot="expandedRowRender" slot-scope="record"> <template slot="expandedRowRender" slot-scope="record">
<a-table <a-table :row-key="client => client.id" :columns="isMobile ? innerMobileColumns : innerColumns"
:row-key="client => client.id" :data-source="getInboundClients(record)" :pagination=pagination(getInboundClients(record))
:columns="isMobile ? innerMobileColumns : innerColumns"
:data-source="getInboundClients(record)"
:pagination=pagination(getInboundClients(record))
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }"> :style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
{{template "component/aClientTable"}} {{template "component/aClientTable"}}
</a-table> </a-table>
@ -676,10 +737,10 @@
refreshing: false, refreshing: false,
refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000, refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000,
subSettings: { subSettings: {
enable : false, enable: false,
subTitle : '', subTitle: '',
subURI : '', subURI: '',
subJsonURI : '', subJsonURI: '',
}, },
remarkModel: '-ieo', remarkModel: '-ieo',
datepicker: 'gregorian', datepicker: 'gregorian',
@ -725,15 +786,15 @@
if (!msg.success) { if (!msg.success) {
return; return;
} }
with(msg.obj){ with (msg.obj) {
this.expireDiff = expireDiff * 86400000; this.expireDiff = expireDiff * 86400000;
this.trafficDiff = trafficDiff * 1073741824; this.trafficDiff = trafficDiff * 1073741824;
this.defaultCert = defaultCert; this.defaultCert = defaultCert;
this.defaultKey = defaultKey; this.defaultKey = defaultKey;
this.tgBotEnable = tgBotEnable; this.tgBotEnable = tgBotEnable;
this.subSettings = { this.subSettings = {
enable : subEnable, enable: subEnable,
subTitle : subTitle, subTitle: subTitle,
subURI: subURI, subURI: subURI,
subJsonURI: subJsonURI subJsonURI: subJsonURI
}; };
@ -762,7 +823,7 @@
if (!this.loadingStates.fetched) { if (!this.loadingStates.fetched) {
this.loadingStates.fetched = true this.loadingStates.fetched = true
} }
if(this.enableFilter){ if (this.enableFilter) {
this.filterInbounds(); this.filterInbounds();
} else { } else {
this.searchInbounds(this.searchKey); this.searchInbounds(this.searchKey);
@ -787,12 +848,15 @@
deactive.push(client.email); deactive.push(client.email);
} }
}); });
clientStats.forEach(client => { clientStats.forEach(stats => {
if (!client.enable) { const exhausted = stats.total > 0 && (stats.up + stats.down) >= stats.total;
depleted.push(client.email); const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
if (expired || exhausted) {
depleted.push(stats.email);
} else { } else {
if ((client.expiryTime > 0 && (client.expiryTime - now < this.expireDiff)) || const expiringSoon = (stats.expiryTime > 0 && (stats.expiryTime - now < this.expireDiff)) ||
(client.total > 0 && (client.total - (client.up + client.down) < this.trafficDiff))) expiring.push(client.email); (stats.total > 0 && (stats.total - (stats.up + stats.down) < this.trafficDiff));
if (expiringSoon) expiring.push(stats.email);
} }
}); });
} else { } else {
@ -843,7 +907,7 @@
this.dbInbounds.forEach(inbound => { this.dbInbounds.forEach(inbound => {
const newInbound = new DBInbound(inbound); const newInbound = new DBInbound(inbound);
const inboundSettings = JSON.parse(inbound.settings); const inboundSettings = JSON.parse(inbound.settings);
if (this.clientCount[inbound.id] && this.clientCount[inbound.id].hasOwnProperty(this.filterBy)){ if (this.clientCount[inbound.id] && this.clientCount[inbound.id].hasOwnProperty(this.filterBy)) {
const list = this.clientCount[inbound.id][this.filterBy]; const list = this.clientCount[inbound.id][this.filterBy];
if (list.length > 0) { if (list.length > 0) {
const filteredSettings = { "clients": [] }; const filteredSettings = { "clients": [] };
@ -861,8 +925,8 @@
}); });
} }
}, },
toggleFilter(){ toggleFilter() {
if(this.enableFilter) { if (this.enableFilter) {
this.searchKey = ''; this.searchKey = '';
} else { } else {
this.filterBy = ''; this.filterBy = '';
@ -1011,7 +1075,7 @@
protocol: inbound.protocol, protocol: inbound.protocol,
settings: inbound.settings.toString(), settings: inbound.settings.toString(),
}; };
if (inbound.canEnableStream()){ if (inbound.canEnableStream()) {
data.streamSettings = inbound.stream.toString(); data.streamSettings = inbound.stream.toString();
} else if (inbound.stream?.sockopt) { } else if (inbound.stream?.sockopt) {
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2); data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
@ -1036,7 +1100,7 @@
protocol: inbound.protocol, protocol: inbound.protocol,
settings: inbound.settings.toString(), settings: inbound.settings.toString(),
}; };
if (inbound.canEnableStream()){ if (inbound.canEnableStream()) {
data.streamSettings = inbound.stream.toString(); data.streamSettings = inbound.stream.toString();
} else if (inbound.stream?.sockopt) { } else if (inbound.stream?.sockopt) {
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2); data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
@ -1133,10 +1197,10 @@
onOk: () => this.submit('/panel/api/inbounds/del/' + dbInboundId), onOk: () => this.submit('/panel/api/inbounds/del/' + dbInboundId),
}); });
}, },
delClient(dbInboundId, client,confirmation = true) { delClient(dbInboundId, client, confirmation = true) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
clientId = this.getClientId(dbInbound.protocol, client); clientId = this.getClientId(dbInbound.protocol, client);
if (confirmation){ if (confirmation) {
this.$confirm({ this.$confirm({
title: '{{ i18n "pages.inbounds.deleteClient"}}' + ' ' + client.email, title: '{{ i18n "pages.inbounds.deleteClient"}}' + ' ' + client.email,
content: '{{ i18n "pages.inbounds.deleteClientContent"}}', content: '{{ i18n "pages.inbounds.deleteClientContent"}}',
@ -1188,10 +1252,10 @@
}, },
checkFallback(dbInbound) { checkFallback(dbInbound) {
newDbInbound = new DBInbound(dbInbound); newDbInbound = new DBInbound(dbInbound);
if (dbInbound.listen.startsWith("@")){ if (dbInbound.listen.startsWith("@")) {
rootInbound = this.inbounds.find((i) => rootInbound = this.inbounds.find((i) =>
i.isTcp && i.isTcp &&
['trojan','vless'].includes(i.protocol) && ['trojan', 'vless'].includes(i.protocol) &&
i.settings.fallbacks.find(f => f.dest === dbInbound.listen) i.settings.fallbacks.find(f => f.dest === dbInbound.listen)
); );
if (rootInbound) { if (rootInbound) {
@ -1213,8 +1277,8 @@
}, },
showInfo(dbInboundId, client) { showInfo(dbInboundId, client) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
index=0; index = 0;
if (dbInbound.isMultiUser()){ if (dbInbound.isMultiUser()) {
inbound = dbInbound.toInbound(); inbound = dbInbound.toInbound();
clients = inbound.clients; clients = inbound.clients;
index = this.findIndexOfClient(dbInbound.protocol, clients, client); index = this.findIndexOfClient(dbInbound.protocol, clients, client);
@ -1222,7 +1286,7 @@
newDbInbound = this.checkFallback(dbInbound); newDbInbound = this.checkFallback(dbInbound);
infoModal.show(newDbInbound, index); infoModal.show(newDbInbound, index);
}, },
switchEnable(dbInboundId,state) { switchEnable(dbInboundId, state) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
dbInbound.enable = state; dbInbound.enable = state;
this.submit(`/panel/api/inbounds/update/${dbInboundId}`, dbInbound); this.submit(`/panel/api/inbounds/update/${dbInboundId}`, dbInbound);
@ -1248,7 +1312,7 @@
return dbInbound.toInbound().clients; return dbInbound.toInbound().clients;
}, },
resetClientTraffic(client, dbInboundId, confirmation = true) { resetClientTraffic(client, dbInboundId, confirmation = true) {
if (confirmation){ if (confirmation) {
this.$confirm({ this.$confirm({
title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' ' + client.email, title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' ' + client.email,
content: '{{ i18n "pages.inbounds.resetTrafficContent"}}', content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
@ -1320,7 +1384,7 @@
clientStats = dbInbound.clientStats.find(stats => stats.email === email); clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!clientStats) return 0; if (!clientStats) return 0;
remained = clientStats.total - (clientStats.up + clientStats.down); remained = clientStats.total - (clientStats.up + clientStats.down);
return remained>0 ? remained : 0; return remained > 0 ? remained : 0;
}, },
clientStatsColor(dbInbound, email) { clientStatsColor(dbInbound, email) {
if (email.length == 0) return ColorUtils.clientUsageColor(); if (email.length == 0) return ColorUtils.clientUsageColor();
@ -1332,23 +1396,23 @@
clientStats = dbInbound.clientStats.find(stats => stats.email === email); clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!clientStats) return 0; if (!clientStats) return 0;
if (clientStats.total == 0) return 100; if (clientStats.total == 0) return 100;
return 100*(clientStats.down + clientStats.up)/clientStats.total; return 100 * (clientStats.down + clientStats.up) / clientStats.total;
}, },
expireProgress(expTime, reset) { expireProgress(expTime, reset) {
now = new Date().getTime(); now = new Date().getTime();
remainedSeconds = expTime < 0 ? -expTime/1000 : (expTime-now)/1000; remainedSeconds = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
resetSeconds = reset * 86400; resetSeconds = reset * 86400;
if (remainedSeconds >= resetSeconds) return 0; if (remainedSeconds >= resetSeconds) return 0;
return 100*(1-(remainedSeconds/resetSeconds)); return 100 * (1 - (remainedSeconds / resetSeconds));
}, },
remainedDays(expTime){ remainedDays(expTime) {
if (expTime == 0) return null; if (expTime == 0) return null;
if (expTime < 0) return TimeFormatter.formatSecond(expTime/-1000); if (expTime < 0) return TimeFormatter.formatSecond(expTime / -1000);
now = new Date().getTime(); now = new Date().getTime();
if (expTime < now) return '{{ i18n "depleted" }}'; if (expTime < now) return '{{ i18n "depleted" }}';
return TimeFormatter.formatSecond((expTime-now)/1000); return TimeFormatter.formatSecond((expTime - now) / 1000);
}, },
statsExpColor(dbInbound, email){ statsExpColor(dbInbound, email) {
if (email.length == 0) return '#7a316f'; if (email.length == 0) return '#7a316f';
clientStats = dbInbound.clientStats.find(stats => stats.email === email); clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!clientStats) return '#7a316f'; if (!clientStats) return '#7a316f';
@ -1369,6 +1433,16 @@
clientStats = dbInbound.clientStats ? dbInbound.clientStats.find(stats => stats.email === email) : null; clientStats = dbInbound.clientStats ? dbInbound.clientStats.find(stats => stats.email === email) : null;
return clientStats ? clientStats['enable'] : true; return clientStats ? clientStats['enable'] : true;
}, },
// Returns true when client's traffic is exhausted or expiry time is passed
isClientDepleted(dbInbound, email) {
if (!email || !dbInbound || !dbInbound.clientStats) return false;
const stats = dbInbound.clientStats.find(s => s.email === email);
if (!stats) return false;
const now = new Date().getTime();
const exhausted = stats.total > 0 && (stats.up + stats.down) >= stats.total;
const expired = stats.expiryTime > 0 && now >= stats.expiryTime;
return exhausted || expired;
},
isClientOnline(email) { isClientOnline(email) {
return this.onlineClients.includes(email); return this.onlineClients.includes(email);
}, },
@ -1395,9 +1469,9 @@
const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
const clients = this.getInboundClients(dbInbound); const clients = this.getInboundClients(dbInbound);
let subLinks = [] let subLinks = []
if (clients != null){ if (clients != null) {
clients.forEach(c => { clients.forEach(c => {
if (c.subId && c.subId.length>0){ if (c.subId && c.subId.length > 0) {
subLinks.push(this.subSettings.subURI + c.subId) subLinks.push(this.subSettings.subURI + c.subId)
} }
}) })
@ -1414,7 +1488,7 @@
value: '', value: '',
okText: '{{ i18n "pages.inbounds.import" }}', okText: '{{ i18n "pages.inbounds.import" }}',
confirm: async (dbInboundText) => { confirm: async (dbInboundText) => {
await this.submit('/panel/api/inbounds/import', {data: dbInboundText}, promptModal); await this.submit('/panel/api/inbounds/import', { data: dbInboundText }, promptModal);
}, },
}); });
}, },
@ -1422,9 +1496,9 @@
let subLinks = [] let subLinks = []
for (const dbInbound of this.dbInbounds) { for (const dbInbound of this.dbInbounds) {
const clients = this.getInboundClients(dbInbound); const clients = this.getInboundClients(dbInbound);
if (clients != null){ if (clients != null) {
clients.forEach(c => { clients.forEach(c => {
if (c.subId && c.subId.length>0){ if (c.subId && c.subId.length > 0) {
subLinks.push(this.subSettings.subURI + c.subId) subLinks.push(this.subSettings.subURI + c.subId)
} }
}) })
@ -1472,11 +1546,11 @@
this.loadingStates.spinning = false; this.loadingStates.spinning = false;
} }
}, },
pagination(obj){ pagination(obj) {
if (this.pageSize > 0 && obj.length>this.pageSize) { if (this.pageSize > 0 && obj.length > this.pageSize) {
// Set page options based on object size // Set page options based on object size
sizeOptions = []; sizeOptions = [];
for (i=this.pageSize;i<=obj.length;i=i+this.pageSize) { for (i = this.pageSize; i <= obj.length; i = i + this.pageSize) {
sizeOptions.push(i.toString()); sizeOptions.push(i.toString());
} }
// Add option to see all in one page // Add option to see all in one page

View file

@ -9,10 +9,7 @@
<a-spin :spinning="loadingStates.spinning" :delay="200" :tip="loadingTip"> <a-spin :spinning="loadingStates.spinning" :delay="200" :tip="loadingTip">
<transition name="list" appear> <transition name="list" appear>
<a-alert type="error" v-if="showAlert && loadingStates.fetched" class="mb-10" <a-alert type="error" v-if="showAlert && loadingStates.fetched" class="mb-10"
message='{{ i18n "secAlertTitle" }}' message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
color="red"
description='{{ i18n "secAlertSsl" }}'
show-icon closable>
</a-alert> </a-alert>
</transition> </transition>
<transition name="list" appear> <transition name="list" appear>
@ -29,8 +26,7 @@
<a-col :sm="24" :md="12"> <a-col :sm="24" :md="12">
<a-row> <a-row>
<a-col :span="12" class="text-center"> <a-col :span="12" class="text-center">
<a-progress type="dashboard" status="normal" <a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color"
:stroke-color="status.cpu.color"
:percent="status.cpu.percent"></a-progress> :percent="status.cpu.percent"></a-progress>
<div> <div>
<b>{{ i18n "pages.index.cpu" }}:</b> [[ CPUFormatter.cpuCoreFormat(status.cpuCores) ]] <b>{{ i18n "pages.index.cpu" }}:</b> [[ CPUFormatter.cpuCoreFormat(status.cpuCores) ]]
@ -38,17 +34,23 @@
<a-icon type="area-chart"></a-icon> <a-icon type="area-chart"></a-icon>
<template slot="title"> <template slot="title">
<div><b>{{ i18n "pages.index.logicalProcessors" }}:</b> [[ (status.logicalPro) ]]</div> <div><b>{{ i18n "pages.index.logicalProcessors" }}:</b> [[ (status.logicalPro) ]]</div>
<div><b>{{ i18n "pages.index.frequency" }}:</b> [[ CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) ]]</div> <div><b>{{ i18n "pages.index.frequency" }}:</b> [[
CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) ]]</div>
</template> </template>
</a-tooltip> </a-tooltip>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<a-button size="small" type="default" class="ml-8" @click="openCpuHistory()">
<a-icon type="history" />
</a-button>
</a-tooltip>
</div> </div>
</a-col> </a-col>
<a-col :span="12" class="text-center"> <a-col :span="12" class="text-center">
<a-progress type="dashboard" status="normal" <a-progress type="dashboard" status="normal" :stroke-color="status.mem.color"
:stroke-color="status.mem.color"
:percent="status.mem.percent"></a-progress> :percent="status.mem.percent"></a-progress>
<div> <div>
<b>{{ i18n "pages.index.memory"}}:</b> [[ SizeFormatter.sizeFormat(status.mem.current) ]] / [[ SizeFormatter.sizeFormat(status.mem.total) ]] <b>{{ i18n "pages.index.memory"}}:</b> [[ SizeFormatter.sizeFormat(status.mem.current) ]] /
[[ SizeFormatter.sizeFormat(status.mem.total) ]]
</div> </div>
</a-col> </a-col>
</a-row> </a-row>
@ -56,19 +58,19 @@
<a-col :sm="24" :md="12"> <a-col :sm="24" :md="12">
<a-row> <a-row>
<a-col :span="12" class="text-center"> <a-col :span="12" class="text-center">
<a-progress type="dashboard" status="normal" <a-progress type="dashboard" status="normal" :stroke-color="status.swap.color"
:stroke-color="status.swap.color"
:percent="status.swap.percent"></a-progress> :percent="status.swap.percent"></a-progress>
<div> <div>
<b>{{ i18n "pages.index.swap" }}:</b> [[ SizeFormatter.sizeFormat(status.swap.current) ]] / [[ SizeFormatter.sizeFormat(status.swap.total) ]] <b>{{ i18n "pages.index.swap" }}:</b> [[ SizeFormatter.sizeFormat(status.swap.current) ]] /
[[ SizeFormatter.sizeFormat(status.swap.total) ]]
</div> </div>
</a-col> </a-col>
<a-col :span="12" class="text-center"> <a-col :span="12" class="text-center">
<a-progress type="dashboard" status="normal" <a-progress type="dashboard" status="normal" :stroke-color="status.disk.color"
:stroke-color="status.disk.color"
:percent="status.disk.percent"></a-progress> :percent="status.disk.percent"></a-progress>
<div> <div>
<b>{{ i18n "pages.index.storage"}}:</b> [[ SizeFormatter.sizeFormat(status.disk.current) ]] / [[ SizeFormatter.sizeFormat(status.disk.total) ]] <b>{{ i18n "pages.index.storage"}}:</b> [[ SizeFormatter.sizeFormat(status.disk.current) ]]
/ [[ SizeFormatter.sizeFormat(status.disk.total) ]]
</div> </div>
</a-col> </a-col>
</a-row> </a-row>
@ -88,7 +90,9 @@
</template> </template>
<template #extra> <template #extra>
<template v-if="status.xray.state != 'error'"> <template v-if="status.xray.state != 'error'">
<a-badge status="processing" :class="({ green: 'xray-running-animation', orange: 'xray-stop-animation' }[status.xray.color]) || 'xray-processing-animation'" :text="status.xray.stateMsg" :color="status.xray.color"/> <a-badge status="processing"
:class="({ green: 'xray-running-animation', orange: 'xray-stop-animation' }[status.xray.color]) || 'xray-processing-animation'"
:text="status.xray.stateMsg" :color="status.xray.color" />
</template> </template>
<template v-else> <template v-else>
<a-popover :overlay-class-name="themeSwitcher.currentTheme"> <a-popover :overlay-class-name="themeSwitcher.currentTheme">
@ -105,7 +109,8 @@
<template slot="content"> <template slot="content">
<span class="max-w-400" v-for="line in status.xray.errorMsg.split('\n')">[[ line ]]</span> <span class="max-w-400" v-for="line in status.xray.errorMsg.split('\n')">[[ line ]]</span>
</template> </template>
<a-badge :text="status.xray.stateMsg" :color="status.xray.color" :class="status.xray.color === 'red' ? 'xray-error-animation' : ''"/> <a-badge :text="status.xray.stateMsg" :color="status.xray.color"
:class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
</a-popover> </a-popover>
</template> </template>
</template> </template>
@ -125,7 +130,8 @@
<a-space direction="horizontal" @click="openSelectV2rayVersion" class="jc-center"> <a-space direction="horizontal" @click="openSelectV2rayVersion" class="jc-center">
<a-icon type="tool"></a-icon> <a-icon type="tool"></a-icon>
<span v-if="!isMobile"> <span v-if="!isMobile">
[[ status.xray.version != 'Unknown' ? `v${status.xray.version}` : '{{ i18n "pages.index.xraySwitch" }}' ]] [[ status.xray.version != 'Unknown' ? `v${status.xray.version}` : '{{ i18n
"pages.index.xraySwitch" }}' ]]
</span> </span>
</a-space> </a-space>
</template> </template>
@ -170,7 +176,8 @@
</a-col> </a-col>
<a-col :sm="24" :lg="12"> <a-col :sm="24" :lg="12">
<a-card title='{{ i18n "pages.index.operationHours" }}' hoverable> <a-card title='{{ i18n "pages.index.operationHours" }}' hoverable>
<a-tag :color="status.xray.color">Xray: [[ TimeFormatter.formatSecond(status.appStats.uptime) ]]</a-tag> <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-tag color="green">OS: [[ TimeFormatter.formatSecond(status.uptime) ]]</a-tag>
</a-card> </a-card>
</a-col> </a-col>
@ -188,7 +195,8 @@
</a-col> </a-col>
<a-col :sm="24" :lg="12"> <a-col :sm="24" :lg="12">
<a-card title='{{ i18n "usage"}}' hoverable> <a-card title='{{ i18n "usage"}}' hoverable>
<a-tag color="green"> {{ i18n "pages.index.memory" }}: [[ SizeFormatter.sizeFormat(status.appStats.mem) ]] </a-tag> <a-tag color="green"> {{ i18n "pages.index.memory" }}: [[
SizeFormatter.sizeFormat(status.appStats.mem) ]] </a-tag>
<a-tag color="green"> {{ i18n "pages.index.threads" }}: [[ status.appStats.threads ]] </a-tag> <a-tag color="green"> {{ i18n "pages.index.threads" }}: [[ status.appStats.threads ]] </a-tag>
</a-card> </a-card>
</a-col> </a-col>
@ -196,7 +204,8 @@
<a-card title='{{ i18n "pages.index.overallSpeed" }}' hoverable> <a-card title='{{ i18n "pages.index.overallSpeed" }}' hoverable>
<a-row :gutter="isMobile ? [8,8] : 0"> <a-row :gutter="isMobile ? [8,8] : 0">
<a-col :span="12"> <a-col :span="12">
<a-custom-statistic title='{{ i18n "pages.index.upload" }}' :value="SizeFormatter.sizeFormat(status.netIO.up)"> <a-custom-statistic title='{{ i18n "pages.index.upload" }}'
:value="SizeFormatter.sizeFormat(status.netIO.up)">
<template #prefix> <template #prefix>
<a-icon type="arrow-up" /> <a-icon type="arrow-up" />
</template> </template>
@ -206,7 +215,8 @@
</a-custom-statistic> </a-custom-statistic>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-custom-statistic title='{{ i18n "pages.index.download" }}' :value="SizeFormatter.sizeFormat(status.netIO.down)"> <a-custom-statistic title='{{ i18n "pages.index.download" }}'
:value="SizeFormatter.sizeFormat(status.netIO.down)">
<template #prefix> <template #prefix>
<a-icon type="arrow-down" /> <a-icon type="arrow-down" />
</template> </template>
@ -222,14 +232,16 @@
<a-card title='{{ i18n "pages.index.totalData" }}' hoverable> <a-card title='{{ i18n "pages.index.totalData" }}' hoverable>
<a-row :gutter="isMobile ? [8,8] : 0"> <a-row :gutter="isMobile ? [8,8] : 0">
<a-col :span="12"> <a-col :span="12">
<a-custom-statistic title='{{ i18n "pages.index.sent" }}' :value="SizeFormatter.sizeFormat(status.netTraffic.sent)"> <a-custom-statistic title='{{ i18n "pages.index.sent" }}'
:value="SizeFormatter.sizeFormat(status.netTraffic.sent)">
<template #prefix> <template #prefix>
<a-icon type="cloud-upload" /> <a-icon type="cloud-upload" />
</template> </template>
</a-custom-statistic> </a-custom-statistic>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-custom-statistic title='{{ i18n "pages.index.received" }}' :value="SizeFormatter.sizeFormat(status.netTraffic.recv)"> <a-custom-statistic title='{{ i18n "pages.index.received" }}'
:value="SizeFormatter.sizeFormat(status.netTraffic.recv)">
<template #prefix> <template #prefix>
<a-icon type="cloud-download" /> <a-icon type="cloud-download" />
</template> </template>
@ -245,7 +257,8 @@
<template #title> <template #title>
{{ i18n "pages.index.toggleIpVisibility" }} {{ i18n "pages.index.toggleIpVisibility" }}
</template> </template>
<a-icon :type="showIp ? 'eye' : 'eye-invisible'" class="fs-1rem" @click="showIp = !showIp"></a-icon> <a-icon :type="showIp ? 'eye' : 'eye-invisible'" class="fs-1rem"
@click="showIp = !showIp"></a-icon>
</a-tooltip> </a-tooltip>
</template> </template>
<a-row :class="showIp ? 'ip-visible' : 'ip-hidden'" :gutter="isMobile ? [8,8] : 0"> <a-row :class="showIp ? 'ip-visible' : 'ip-hidden'" :gutter="isMobile ? [8,8] : 0">
@ -292,55 +305,54 @@
</a-spin> </a-spin>
</a-layout-content> </a-layout-content>
</a-layout> </a-layout>
<a-modal id="version-modal" v-model="versionModal.visible" title='{{ i18n "pages.index.xraySwitch" }}' :closable="true" <a-modal id="version-modal" v-model="versionModal.visible" title='{{ i18n "pages.index.xraySwitch" }}'
@ok="() => versionModal.visible = false" :class="themeSwitcher.currentTheme" footer=""> :closable="true" @ok="() => versionModal.visible = false" :class="themeSwitcher.currentTheme" footer="">
<a-collapse default-active-key="1"> <a-collapse default-active-key="1">
<a-collapse-panel key="1" header='Xray'> <a-collapse-panel key="1" header='Xray'>
<a-alert type="warning" class="mb-12 w-100" message='{{ i18n "pages.index.xraySwitchClickDesk" }}' show-icon></a-alert> <a-alert type="warning" class="mb-12 w-100" message='{{ i18n "pages.index.xraySwitchClickDesk" }}'
show-icon></a-alert>
<a-list class="ant-version-list w-100" bordered> <a-list class="ant-version-list w-100" bordered>
<a-list-item class="ant-version-list-item" v-for="version, index in versionModal.versions"> <a-list-item class="ant-version-list-item" v-for="version, index in versionModal.versions">
<a-tag :color="index % 2 == 0 ? 'purple' : 'green'">[[ version ]]</a-tag> <a-tag :color="index % 2 == 0 ? 'purple' : 'green'">[[ version ]]</a-tag>
<a-radio :class="themeSwitcher.currentTheme" :checked="version === `v${status.xray.version}`" @click="switchV2rayVersion(version)"></a-radio> <a-radio :class="themeSwitcher.currentTheme" :checked="version === `v${status.xray.version}`"
@click="switchV2rayVersion(version)"></a-radio>
</a-list-item> </a-list-item>
</a-list> </a-list>
</a-collapse-panel> </a-collapse-panel>
<a-collapse-panel key="2" header='Geofiles'> <a-collapse-panel key="2" header='Geofiles'>
<a-list class="ant-version-list w-100" bordered> <a-list class="ant-version-list w-100" bordered>
<a-list-item class="ant-version-list-item" v-for="file, index in ['geosite.dat', 'geoip.dat', 'geosite_IR.dat', 'geoip_IR.dat', 'geosite_RU.dat', 'geoip_RU.dat']"> <a-list-item class="ant-version-list-item"
v-for="file, index in ['geosite.dat', 'geoip.dat', 'geosite_IR.dat', 'geoip_IR.dat', 'geosite_RU.dat', 'geoip_RU.dat']">
<a-tag :color="index % 2 == 0 ? 'purple' : 'green'">[[ file ]]</a-tag> <a-tag :color="index % 2 == 0 ? 'purple' : 'green'">[[ file ]]</a-tag>
<a-icon type="reload" @click="updateGeofile(file)" class="mr-8"/> <a-icon type="reload" @click="updateGeofile(file)" class="mr-8" />
</a-list-item> </a-list-item>
</a-list> </a-list>
<div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n "pages.index.geofilesUpdateAll" }}</a-button></div> <div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n
"pages.index.geofilesUpdateAll" }}</a-button></div>
</a-collapse-panel> </a-collapse-panel>
</a-collapse> </a-collapse>
</a-modal> </a-modal>
<a-modal id="log-modal" v-model="logModal.visible" <a-modal id="log-modal" v-model="logModal.visible" :closable="true" @cancel="() => logModal.visible = false"
:closable="true" @cancel="() => logModal.visible = false" :class="themeSwitcher.currentTheme" width="800px" footer="">
:class="themeSwitcher.currentTheme"
width="800px" footer="">
<template slot="title"> <template slot="title">
{{ i18n "pages.index.logs" }} {{ i18n "pages.index.logs" }}
<a-icon :spin="logModal.loading" <a-icon :spin="logModal.loading" type="sync" class="va-middle ml-10" :disabled="logModal.loading"
type="sync"
class="va-middle ml-10"
:disabled="logModal.loading"
@click="openLogs()"> @click="openLogs()">
</a-icon> </a-icon>
</template> </template>
<a-form layout="inline"> <a-form layout="inline">
<a-form-item class="mr-05"> <a-form-item class="mr-05">
<a-input-group compact> <a-input-group compact>
<a-select size="small" v-model="logModal.rows" class="w-70" <a-select size="small" v-model="logModal.rows" class="w-70" @change="openLogs()"
@change="openLogs()" :dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="10">10</a-select-option> <a-select-option value="10">10</a-select-option>
<a-select-option value="20">20</a-select-option> <a-select-option value="20">20</a-select-option>
<a-select-option value="50">50</a-select-option> <a-select-option value="50">50</a-select-option>
<a-select-option value="100">100</a-select-option> <a-select-option value="100">100</a-select-option>
<a-select-option value="500">500</a-select-option> <a-select-option value="500">500</a-select-option>
</a-select> </a-select>
<a-select size="small" v-model="logModal.level" class="w-95" <a-select size="small" v-model="logModal.level" class="w-95" @change="openLogs()"
@change="openLogs()" :dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="debug">Debug</a-select-option> <a-select-option value="debug">Debug</a-select-option>
<a-select-option value="info">Info</a-select-option> <a-select-option value="info">Info</a-select-option>
<a-select-option value="notice">Notice</a-select-option> <a-select-option value="notice">Notice</a-select-option>
@ -353,31 +365,25 @@
<a-checkbox v-model="logModal.syslog" @change="openLogs()">SysLog</a-checkbox> <a-checkbox v-model="logModal.syslog" @change="openLogs()">SysLog</a-checkbox>
</a-form-item> </a-form-item>
<a-form-item style="float: right;"> <a-form-item style="float: right;">
<a-button type="primary" icon="download" @click="FileManager.downloadTextFile(logModal.logs?.join('\n'), 'x-ui.log')"></a-button> <a-button type="primary" icon="download"
@click="FileManager.downloadTextFile(logModal.logs?.join('\n'), 'x-ui.log')"></a-button>
</a-form-item> </a-form-item>
</a-form> </a-form>
<div class="ant-input log-container" v-html="logModal.formattedLogs"></div> <div class="ant-input log-container" v-html="logModal.formattedLogs"></div>
</a-modal> </a-modal>
<a-modal id="xraylog-modal" <a-modal id="xraylog-modal" v-model="xraylogModal.visible" :closable="true"
v-model="xraylogModal.visible" @cancel="() => xraylogModal.visible = false" :class="themeSwitcher.currentTheme" width="80vw" footer="">
:closable="true" @cancel="() => xraylogModal.visible = false"
:class="themeSwitcher.currentTheme"
width="80vw"
footer="">
<template slot="title"> <template slot="title">
{{ i18n "pages.index.logs" }} {{ i18n "pages.index.logs" }}
<a-icon :spin="xraylogModal.loading" <a-icon :spin="xraylogModal.loading" type="sync" class="va-middle ml-10" :disabled="xraylogModal.loading"
type="sync"
class="va-middle ml-10"
:disabled="xraylogModal.loading"
@click="openXrayLogs()"> @click="openXrayLogs()">
</a-icon> </a-icon>
</template> </template>
<a-form layout="inline"> <a-form layout="inline">
<a-form-item class="mr-05"> <a-form-item class="mr-05">
<a-input-group compact> <a-input-group compact>
<a-select size="small" v-model="xraylogModal.rows" class="w-70" <a-select size="small" v-model="xraylogModal.rows" class="w-70" @change="openXrayLogs()"
@change="openXrayLogs()" :dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="10">10</a-select-option> <a-select-option value="10">10</a-select-option>
<a-select-option value="20">20</a-select-option> <a-select-option value="20">20</a-select-option>
<a-select-option value="50">50</a-select-option> <a-select-option value="50">50</a-select-option>
@ -395,24 +401,21 @@
<a-checkbox v-model="xraylogModal.showProxy" @change="openXrayLogs()">Proxy</a-checkbox> <a-checkbox v-model="xraylogModal.showProxy" @change="openXrayLogs()">Proxy</a-checkbox>
</a-form-item> </a-form-item>
<a-form-item style="float: right;"> <a-form-item style="float: right;">
<a-button type="primary" icon="download" @click="FileManager.downloadTextFile(xraylogModal.logs?.join('\n'), 'x-ui.log')"></a-button> <a-button type="primary" icon="download"
@click="FileManager.downloadTextFile(xraylogModal.logs?.join('\n'), 'x-ui.log')"></a-button>
</a-form-item> </a-form-item>
</a-form> </a-form>
<div class="ant-input log-container" v-html="xraylogModal.formattedLogs"></div> <div class="ant-input log-container" v-html="xraylogModal.formattedLogs"></div>
</a-modal> </a-modal>
<a-modal id="backup-modal" <a-modal id="backup-modal" v-model="backupModal.visible" title='{{ i18n "pages.index.backupTitle" }}' :closable="true"
v-model="backupModal.visible" footer="" :class="themeSwitcher.currentTheme">
title='{{ i18n "pages.index.backupTitle" }}'
:closable="true"
footer=""
:class="themeSwitcher.currentTheme">
<a-list class="ant-backup-list w-100" bordered> <a-list class="ant-backup-list w-100" bordered>
<a-list-item class="ant-backup-list-item"> <a-list-item class="ant-backup-list-item">
<a-list-item-meta> <a-list-item-meta>
<template #title>{{ i18n "pages.index.exportDatabase" }}</template> <template #title>{{ i18n "pages.index.exportDatabase" }}</template>
<template #description>{{ i18n "pages.index.exportDatabaseDesc" }}</template> <template #description>{{ i18n "pages.index.exportDatabaseDesc" }}</template>
</a-list-item-meta> </a-list-item-meta>
<a-button @click="exportDatabase()" type="primary" icon="download"/> <a-button @click="exportDatabase()" type="primary" icon="download" />
</a-list-item> </a-list-item>
<a-list-item class="ant-backup-list-item"> <a-list-item class="ant-backup-list-item">
<a-list-item-meta> <a-list-item-meta>
@ -423,6 +426,28 @@
</a-list-item> </a-list-item>
</a-list> </a-list>
</a-modal> </a-modal>
<!-- CPU History Modal -->
<a-modal id="cpu-history-modal" v-model="cpuHistoryModal.visible" :closable="true"
@cancel="() => cpuHistoryModal.visible = false" :class="themeSwitcher.currentTheme" width="900px" footer="">
<template slot="title">
CPU History
<a-select size="small" v-model="cpuHistoryModal.bucket" class="ml-10" style="width: 140px"
@change="fetchCpuHistoryBucket">
<a-select-option :value="2">2s</a-select-option>
<a-select-option :value="30">30s</a-select-option>
<a-select-option :value="60">1m</a-select-option>
<a-select-option :value="120">2m</a-select-option>
<a-select-option :value="180">3m</a-select-option>
<a-select-option :value="300">5m</a-select-option>
</a-select>
</template>
<div style="padding: 8px 0;">
<sparkline :data="cpuHistoryLong" :labels="cpuHistoryLabels" :vb-width="840" :height="220"
:stroke="status.cpu.color" :stroke-width="2.2" :show-grid="true" :show-axes="true" :tick-count-x="5"
:max-points="cpuHistoryLong.length" :fill-opacity="0.18" :marker-radius="3.2" :show-tooltip="true" />
<div style="margin-top:4px;font-size:11px;opacity:0.65">Timeframe: [[ cpuHistoryModal.bucket ]] sec per point (total [[ cpuHistoryLong.length ]] points)</div>
</div>
</a-modal>
</a-layout> </a-layout>
{{template "page/body_scripts" .}} {{template "page/body_scripts" .}}
{{template "component/aSidebar" .}} {{template "component/aSidebar" .}}
@ -430,6 +455,192 @@
{{template "component/aCustomStatistic" .}} {{template "component/aCustomStatistic" .}}
{{template "modals/textModal"}} {{template "modals/textModal"}}
<script> <script>
// Tiny Sparkline component using an inline SVG polyline
Vue.component('sparkline', {
props: {
data: { type: Array, required: true },
// viewBox width for drawing space; SVG width will be 100% of container
vbWidth: { type: Number, default: 320 },
height: { type: Number, default: 80 },
stroke: { type: String, default: '#008771' },
strokeWidth: { type: Number, default: 2 },
maxPoints: { type: Number, default: 120 },
showGrid: { type: Boolean, default: true },
gridColor: { type: String, default: 'rgba(255,255,255,0.08)' },
fillOpacity: { type: Number, default: 0.15 },
showMarker: { type: Boolean, default: true },
markerRadius: { type: Number, default: 2.8 },
// New opts for axes/labels/tooltip
labels: { type: Array, default: () => [] }, // same length as data for x labels (e.g., timestamps)
showAxes: { type: Boolean, default: false },
yTickStep: { type: Number, default: 25 }, // percent ticks
tickCountX: { type: Number, default: 4 },
paddingLeft: { type: Number, default: 32 },
paddingRight: { type: Number, default: 6 },
paddingTop: { type: Number, default: 6 },
paddingBottom: { type: Number, default: 20 },
showTooltip: { type: Boolean, default: false },
},
data() {
return {
hoverIdx: -1,
}
},
computed: {
viewBoxAttr() {
return '0 0 ' + this.vbWidth + ' ' + this.height
},
drawWidth() {
return Math.max(1, this.vbWidth - this.paddingLeft - this.paddingRight)
},
drawHeight() {
return Math.max(1, this.height - this.paddingTop - this.paddingBottom)
},
nPoints() {
return Math.min(this.data.length, this.maxPoints)
},
dataSlice() {
const n = this.nPoints
if (n === 0) return []
return this.data.slice(this.data.length - n)
},
labelsSlice() {
const n = this.nPoints
if (!this.labels || this.labels.length === 0 || n === 0) return []
const start = Math.max(0, this.labels.length - n)
return this.labels.slice(start)
},
pointsArr() {
const n = this.nPoints
if (n === 0) return []
const slice = this.dataSlice
const max = 100
const w = this.drawWidth
const h = this.drawHeight
const dx = n > 1 ? w / (n - 1) : 0
return slice.map((v, i) => {
const x = Math.round(this.paddingLeft + i * dx)
const y = Math.round(this.paddingTop + (h - (Math.max(0, Math.min(100, v)) / max) * h))
return [x, y]
})
},
points() {
return this.pointsArr.map(p => `${p[0]},${p[1]}`).join(' ')
},
areaPath() {
if (this.pointsArr.length === 0) return ''
const first = this.pointsArr[0]
const last = this.pointsArr[this.pointsArr.length - 1]
const line = this.points
// Close to bottom to create an area fill
return `M ${first[0]},${this.paddingTop + this.drawHeight} L ${line.replace(/ /g, ' L ')} L ${last[0]},${this.paddingTop + this.drawHeight} Z`
},
gridLines() {
if (!this.showGrid) return []
const h = this.drawHeight
const w = this.drawWidth
// draw at 25%, 50%, 75%
return [0.25, 0.5, 0.75]
.map(r => Math.round(this.paddingTop + h * r))
.map(y => ({ x1: this.paddingLeft, y1: y, x2: this.paddingLeft + w, y2: y }))
},
lastPoint() {
if (this.pointsArr.length === 0) return null
return this.pointsArr[this.pointsArr.length - 1]
},
yTicks() {
if (!this.showAxes) return []
const step = Math.max(1, this.yTickStep)
const ticks = []
for (let p = 0; p <= 100; p += step) {
const y = Math.round(this.paddingTop + (this.drawHeight - (p / 100) * this.drawHeight))
ticks.push({ y, label: `${p}%` })
}
return ticks
},
xTicks() {
if (!this.showAxes) return []
const labels = this.labelsSlice
const n = this.nPoints
const m = Math.max(2, this.tickCountX)
const ticks = []
if (n === 0) return ticks
const w = this.drawWidth
const dx = n > 1 ? w / (n - 1) : 0
const positions = []
for (let i = 0; i < m; i++) {
const idx = Math.round((i * (n - 1)) / (m - 1))
positions.push(idx)
}
positions.forEach(idx => {
const label = labels[idx] != null ? String(labels[idx]) : String(idx)
const x = Math.round(this.paddingLeft + idx * dx)
ticks.push({ x, label })
})
return ticks
},
},
methods: {
onMouseMove(evt) {
if (!this.showTooltip || this.pointsArr.length === 0) return
const rect = evt.currentTarget.getBoundingClientRect()
const px = evt.clientX - rect.left
// translate to viewBox space
const x = (px / rect.width) * this.vbWidth
const n = this.nPoints
const dx = n > 1 ? this.drawWidth / (n - 1) : 0
const idx = Math.max(0, Math.min(n - 1, Math.round((x - this.paddingLeft) / (dx || 1))))
this.hoverIdx = idx
},
onMouseLeave() {
this.hoverIdx = -1
},
fmtHoverText() {
const labels = this.labelsSlice
const idx = this.hoverIdx
if (idx < 0 || idx >= this.dataSlice.length) return ''
const raw = Math.max(0, Math.min(100, Number(this.dataSlice[idx] || 0)))
const val = Number.isFinite(raw) ? raw.toFixed(2) : raw
const lab = labels[idx] != null ? labels[idx] : ''
return `${val}%${lab ? ' • ' + lab : ''}`
},
},
template: `
<svg width="100%" :height="height" :viewBox="viewBoxAttr" preserveAspectRatio="none" style="display:block"
@mousemove="onMouseMove" @mouseleave="onMouseLeave">
<defs>
<linearGradient id="spkGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" :stop-color="stroke" :stop-opacity="fillOpacity"/>
<stop offset="100%" :stop-color="stroke" stop-opacity="0"/>
</linearGradient>
</defs>
<g v-if="showGrid">
<line v-for="(g,i) in gridLines" :key="i" :x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2" :stroke="gridColor" stroke-width="1"/>
</g>
<g v-if="showAxes">
<!-- Y ticks/labels -->
<g v-for="(t,i) in yTicks" :key="'y'+i">
<text :x="Math.max(0, paddingLeft - 4)" :y="t.y + 4" text-anchor="end" font-size="10" fill="rgba(200,200,200,0.8)" v-text="t.label"></text>
</g>
<!-- X ticks/labels -->
<g v-for="(t,i) in xTicks" :key="'x'+i">
<text :x="t.x" :y="paddingTop + drawHeight + 14" text-anchor="middle" font-size="10" fill="rgba(200,200,200,0.8)" v-text="t.label"></text>
</g>
</g>
<path v-if="areaPath" :d="areaPath" fill="url(#spkGrad)" stroke="none" />
<polyline :points="points" fill="none" :stroke="stroke" :stroke-width="strokeWidth" stroke-linecap="round" stroke-linejoin="round"/>
<circle v-if="showMarker && lastPoint" :cx="lastPoint[0]" :cy="lastPoint[1]" :r="markerRadius" :fill="stroke" />
<!-- Hover marker/tooltip -->
<g v-if="showTooltip && hoverIdx >= 0">
<line :x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]" :y1="paddingTop" :y2="paddingTop + drawHeight" stroke="rgba(255,255,255,0.25)" stroke-width="1" />
<circle :cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]" r="3.5" :fill="stroke" />
<text :x="pointsArr[hoverIdx][0]" :y="paddingTop + 12" text-anchor="middle" font-size="11" fill="#fff" style="paint-order: stroke; stroke: rgba(0,0,0,0.35); stroke-width: 3;" v-text="fmtHoverText()"></text>
</g>
</svg>
`,
})
class CurTotal { class CurTotal {
constructor(current, total) { constructor(current, total) {
@ -473,7 +684,7 @@
this.udpCount = 0; this.udpCount = 0;
this.uptime = 0; this.uptime = 0;
this.appUptime = 0; this.appUptime = 0;
this.appStats = {threads: 0, mem: 0, uptime: 0}; this.appStats = { threads: 0, mem: 0, uptime: 0 };
this.xray = { state: 'stop', stateMsg: "", errorMsg: "", version: "", color: "" }; this.xray = { state: 'stop', stateMsg: "", errorMsg: "", version: "", color: "" };
@ -509,7 +720,7 @@
break; break;
case 'error': case 'error':
this.xray.color = "red"; this.xray.color = "red";
this.xray.stateMsg ='{{ i18n "pages.index.xrayStatusError" }}'; this.xray.stateMsg = '{{ i18n "pages.index.xrayStatusError" }}';
break; break;
default: default:
this.xray.color = "gray"; this.xray.color = "gray";
@ -545,30 +756,30 @@
}, },
formatLogs(logs) { formatLogs(logs) {
let formattedLogs = ''; let formattedLogs = '';
const levels = ["DEBUG","INFO","NOTICE","WARNING","ERROR"]; const levels = ["DEBUG", "INFO", "NOTICE", "WARNING", "ERROR"];
const levelColors = ["#3c89e8","#008771","#008771","#f37b24","#e04141","#bcbcbc"]; const levelColors = ["#3c89e8", "#008771", "#008771", "#f37b24", "#e04141", "#bcbcbc"];
logs.forEach((log, index) => { logs.forEach((log, index) => {
let [data, message] = log.split(" - ",2); let [data, message] = log.split(" - ", 2);
const parts = data.split(" ") const parts = data.split(" ")
if(index>0) formattedLogs += '<br>'; if (index > 0) formattedLogs += '<br>';
if (parts.length === 3) { if (parts.length === 3) {
const d = parts[0]; const d = parts[0];
const t = parts[1]; const t = parts[1];
const level = parts[2]; const level = parts[2];
const levelIndex = levels.indexOf(level,levels) || 5; const levelIndex = levels.indexOf(level, levels) || 5;
//formattedLogs += `<span style="color: gray;">${index + 1}.</span>`; //formattedLogs += `<span style="color: gray;">${index + 1}.</span>`;
formattedLogs += `<span style="color: ${levelColors[0]};">${d} ${t}</span> `; formattedLogs += `<span style="color: ${levelColors[0]};">${d} ${t}</span> `;
formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${level}</span>`; formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${level}</span>`;
} else { } else {
const levelIndex = levels.indexOf(data,levels) || 5; const levelIndex = levels.indexOf(data, levels) || 5;
formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${data}</span>`; formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${data}</span>`;
} }
if(message){ if (message) {
if(message.startsWith("XRAY:")) if (message.startsWith("XRAY:"))
message = "<b>XRAY: </b>" + message.substring(5); message = "<b>XRAY: </b>" + message.substring(5);
else else
message = "<b>X-UI: </b>" + message; message = "<b>X-UI: </b>" + message;
@ -601,11 +812,11 @@
let formattedLogs = ''; let formattedLogs = '';
logs.forEach((log, index) => { logs.forEach((log, index) => {
if(index > 0) formattedLogs += '<br>'; if (index > 0) formattedLogs += '<br>';
const parts = log.split(' '); const parts = log.split(' ');
if(parts.length === 10) { if (parts.length === 10) {
const dateTime = `<b>${parts[0]} ${parts[1]}</b>`; const dateTime = `<b>${parts[0]} ${parts[1]}</b>`;
const from = `<b>${parts[3]}</b>`; const from = `<b>${parts[3]}</b>`;
const to = `<b>${parts[5].replace(/^\/+/, "")}</b>`; const to = `<b>${parts[5].replace(/^\/+/, "")}</b>`;
@ -659,6 +870,10 @@ ${dateTime}
spinning: false spinning: false
}, },
status: new Status(), status: new Status(),
cpuHistory: [], // small live widget history
cpuHistoryLong: [], // aggregated points from backend
cpuHistoryLabels: [],
cpuHistoryModal: { visible: false, bucket: 2 },
versionModal, versionModal,
logModal, logModal,
xraylogModal, xraylogModal,
@ -689,6 +904,43 @@ ${dateTime}
}, },
setStatus(data) { setStatus(data) {
this.status = new Status(data); this.status = new Status(data);
// Push CPU percent into history (clamped 0..100)
const v = Math.max(0, Math.min(100, Number(data?.cpu ?? 0)))
this.cpuHistory.push(v)
const maxPoints = this.isMobile ? 60 : 120
if (this.cpuHistory.length > maxPoints) {
this.cpuHistory.splice(0, this.cpuHistory.length - maxPoints)
}
// If modal open, refresh current bucketed data
if (this.cpuHistoryModal.visible) {
this.fetchCpuHistoryBucket()
}
},
openCpuHistory() {
this.cpuHistoryModal.visible = true
this.fetchCpuHistoryBucket()
},
async fetchCpuHistoryBucket() {
const bucket = this.cpuHistoryModal.bucket || 2
try {
const msg = await HttpUtil.get(`/panel/api/server/cpuHistory/${bucket}`)
if (msg.success && Array.isArray(msg.obj)) {
const vals = []
const labels = []
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')
labels.push(bucket>=60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`)
vals.push(Math.max(0, Math.min(100, p.cpu)))
}
this.cpuHistoryLabels = labels
this.cpuHistoryLong = vals
}
} catch(e) {
console.error('Failed to fetch bucketed cpu history', e)
}
}, },
async openSelectV2rayVersion() { async openSelectV2rayVersion() {
this.loading(true); this.loading(true);
@ -751,9 +1003,9 @@ ${dateTime}
return; return;
} }
}, },
async openLogs(){ async openLogs() {
logModal.loading = true; logModal.loading = true;
const msg = await HttpUtil.post('/panel/api/server/logs/'+logModal.rows,{level: logModal.level, syslog: logModal.syslog}); const msg = await HttpUtil.post('/panel/api/server/logs/' + logModal.rows, { level: logModal.level, syslog: logModal.syslog });
if (!msg.success) { if (!msg.success) {
return; return;
} }
@ -761,9 +1013,9 @@ ${dateTime}
await PromiseUtil.sleep(500); await PromiseUtil.sleep(500);
logModal.loading = false; logModal.loading = false;
}, },
async openXrayLogs(){ async openXrayLogs() {
xraylogModal.loading = true; xraylogModal.loading = true;
const msg = await HttpUtil.post('/panel/api/server/xraylogs/'+xraylogModal.rows,{filter: xraylogModal.filter, showDirect: xraylogModal.showDirect, showBlocked: xraylogModal.showBlocked, showProxy: xraylogModal.showProxy}); const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, { filter: xraylogModal.filter, showDirect: xraylogModal.showDirect, showBlocked: xraylogModal.showBlocked, showProxy: xraylogModal.showProxy });
if (!msg.success) { if (!msg.success) {
return; return;
} }
@ -819,6 +1071,7 @@ ${dateTime}
fileInput.click(); fileInput.click();
}, },
}, },
computed: {},
async mounted() { async mounted() {
if (window.location.protocol !== "https:") { if (window.location.protocol !== "https:") {
this.showAlert = true; this.showAlert = true;

View file

@ -180,9 +180,9 @@
<tr> <tr>
<td>{{ i18n "status" }}</td> <td>{{ i18n "status" }}</td>
<td> <td>
<a-tag v-if="isEnable" color="green">{{ i18n "enabled" }}</a-tag> <a-tag v-if="isEnable && isActive && !isDepleted" color="green">{{ i18n "enabled" }}</a-tag>
<a-tag v-else>{{ i18n "disabled" }}</a-tag> <a-tag v-if="!isEnable && !isDepleted">{{ i18n "disabled" }}</a-tag>
<a-tag v-if="!isActive" color="red">{{ i18n "depleted" }}</a-tag> <a-tag v-if="isDepleted" color="red">{{ i18n "depleted" }}</a-tag>
</td> </td>
</tr> </tr>
<tr v-if="infoModal.clientStats"> <tr v-if="infoModal.clientStats">
@ -587,6 +587,14 @@
} }
return infoModal.dbInbound.isEnable; return infoModal.dbInbound.isEnable;
}, },
get isDepleted() {
const stats = this.infoModal.clientStats;
if (!stats) return false;
const now = new Date().getTime();
const expired = stats.expiryTime > 0 && now >= stats.expiryTime;
const exhausted = stats.total > 0 && (stats.up + stats.down) >= stats.total;
return expired || exhausted;
},
}, },
methods: { methods: {
copy(content) { copy(content) {

165
web/html/servers.html Normal file
View file

@ -0,0 +1,165 @@
{{template "header" .}}
<div id="app" class="row" v-cloak>
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">Server Management</h3>
<div class="card-tools">
<button class="btn btn-primary" @click="showAddModal">Add Server</button>
</div>
</div>
<div class="card-body">
<table class="table table-bordered">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Address</th>
<th>Port</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="(server, index) in servers">
<td>{{index + 1}}</td>
<td>{{server.name}}</td>
<td>{{server.address}}</td>
<td>{{server.port}}</td>
<td>
<span v-if="server.enable" class="badge bg-success">Yes</span>
<span v-else class="badge bg-danger">No</span>
</td>
<td>
<button class="btn btn-info btn-sm" @click="showEditModal(server)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteServer(server.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Add/Edit Modal -->
<div class="modal fade" id="serverModal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{modal.title}}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form>
<div class="form-group">
<label>Name</label>
<input type="text" class="form-control" v-model="modal.server.name">
</div>
<div class="form-group">
<label>Address (IP or Domain)</label>
<input type="text" class="form-control" v-model="modal.server.address">
</div>
<div class="form-group">
<label>Port</label>
<input type="number" class="form-control" v-model.number="modal.server.port">
</div>
<div class="form-group">
<label>API Key</label>
<input type="text" class="form-control" v-model="modal.server.apiKey">
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" v-model="modal.server.enable">
<label class="form-check-label">Enabled</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" @click="saveServer">Save</button>
</div>
</div>
</div>
</div>
</div>
<script>
const app = new Vue({
el: '#app',
data: {
servers: [],
modal: {
title: '',
server: {
name: '',
address: '',
port: 0,
apiKey: '',
enable: true
}
}
},
methods: {
loadServers() {
axios.get('{{.base_path}}server/list')
.then(response => {
this.servers = response.data.obj;
})
.catch(error => {
alert(error.response.data.msg);
});
},
showAddModal() {
this.modal.title = 'Add Server';
this.modal.server = {
name: '',
address: '',
port: 0,
apiKey: '',
enable: true
};
$('#serverModal').modal('show');
},
showEditModal(server) {
this.modal.title = 'Edit Server';
this.modal.server = Object.assign({}, server);
$('#serverModal').modal('show');
},
saveServer() {
let url = '{{.base_path}}server/add';
if (this.modal.server.id) {
url = `{{.base_path}}server/update/${this.modal.server.id}`;
}
axios.post(url, this.modal.server)
.then(response => {
alert(response.data.msg);
$('#serverModal').modal('hide');
this.loadServers();
})
.catch(error => {
alert(error.response.data.msg);
});
},
deleteServer(id) {
if (!confirm('Are you sure you want to delete this server?')) {
return;
}
axios.post(`{{.base_path}}server/del/${id}`)
.then(response => {
alert(response.data.msg);
this.loadServers();
})
.catch(error => {
alert(error.response.data.msg);
});
}
},
mounted() {
this.loadServers();
}
});
</script>
{{template "footer" .}}

View file

@ -535,7 +535,9 @@
switch (o.protocol) { switch (o.protocol) {
case Protocols.VMess: case Protocols.VMess:
case Protocols.VLESS: case Protocols.VLESS:
serverObj = o.settings.vnext; if (o.settings && o.settings.address && o.settings.port) {
return [o.settings.address + ':' + o.settings.port];
}
break; break;
case Protocols.HTTP: case Protocols.HTTP:
case Protocols.Mixed: case Protocols.Mixed:

34
web/middleware/auth.go Normal file
View file

@ -0,0 +1,34 @@
package middleware
import (
"net/http"
"x-ui/web/service"
"github.com/gin-gonic/gin"
)
func ApiAuth() gin.HandlerFunc {
return func(c *gin.Context) {
apiKey := c.GetHeader("Api-Key")
if apiKey == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "API key is required"})
c.Abort()
return
}
settingService := service.SettingService{}
panelAPIKey, err := settingService.GetAPIKey()
if err != nil || panelAPIKey == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "API key not configured on the panel"})
c.Abort()
return
}
if apiKey != panelAPIKey {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
c.Abort()
return
}
c.Next()
}
}

View file

@ -1,8 +1,11 @@
package service package service
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -617,6 +620,11 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
} }
s.xrayApi.Close() s.xrayApi.Close()
if err == nil {
body, _ := json.Marshal(data)
s.syncWithSlaves("POST", "/panel/inbound/api/addClient", bytes.NewReader(body))
}
return needRestart, tx.Save(oldInbound).Error return needRestart, tx.Save(oldInbound).Error
} }
@ -705,6 +713,11 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
s.xrayApi.Close() s.xrayApi.Close()
} }
} }
if err == nil {
s.syncWithSlaves("POST", fmt.Sprintf("/panel/inbound/api/%d/delClient/%s", inboundId, clientId), nil)
}
return needRestart, db.Save(oldInbound).Error return needRestart, db.Save(oldInbound).Error
} }
@ -880,6 +893,12 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
logger.Debug("Client old email not found") logger.Debug("Client old email not found")
needRestart = true needRestart = true
} }
if err == nil {
body, _ := json.Marshal(data)
s.syncWithSlaves("POST", fmt.Sprintf("/panel/inbound/api/updateClient/%s", clientId), bytes.NewReader(body))
}
return needRestart, tx.Save(oldInbound).Error return needRestart, tx.Save(oldInbound).Error
} }
@ -1272,7 +1291,7 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model
clientTraffic.Email = client.Email clientTraffic.Email = client.Email
clientTraffic.Total = client.TotalGB clientTraffic.Total = client.TotalGB
clientTraffic.ExpiryTime = client.ExpiryTime clientTraffic.ExpiryTime = client.ExpiryTime
clientTraffic.Enable = true clientTraffic.Enable = client.Enable
clientTraffic.Up = 0 clientTraffic.Up = 0
clientTraffic.Down = 0 clientTraffic.Down = 0
clientTraffic.Reset = client.Reset clientTraffic.Reset = client.Reset
@ -1285,7 +1304,7 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod
result := tx.Model(xray.ClientTraffic{}). result := tx.Model(xray.ClientTraffic{}).
Where("email = ?", email). Where("email = ?", email).
Updates(map[string]any{ Updates(map[string]any{
"enable": true, "enable": client.Enable,
"email": client.Email, "email": client.Email,
"total": client.TotalGB, "total": client.TotalGB,
"expiry_time": client.ExpiryTime, "expiry_time": client.ExpiryTime,
@ -1837,8 +1856,14 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
whereText += "= ?" whereText += "= ?"
} }
// Only consider truly depleted clients: expired OR traffic exhausted
now := time.Now().Unix() * 1000
depletedClients := []xray.ClientTraffic{} depletedClients := []xray.ClientTraffic{}
err = db.Model(xray.ClientTraffic{}).Where(whereText+" and enable = ?", id, false).Select("inbound_id, GROUP_CONCAT(email) as email").Group("inbound_id").Find(&depletedClients).Error err = db.Model(xray.ClientTraffic{}).
Where(whereText+" and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))", id, now).
Select("inbound_id, GROUP_CONCAT(email) as email").
Group("inbound_id").
Find(&depletedClients).Error
if err != nil { if err != nil {
return err return err
} }
@ -1889,7 +1914,8 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
} }
} }
err = tx.Where(whereText+" and enable = ?", id, false).Delete(xray.ClientTraffic{}).Error // Delete stats only for truly depleted clients
err = tx.Where(whereText+" and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))", id, now).Delete(xray.ClientTraffic{}).Error
if err != nil { if err != nil {
return err return err
} }
@ -1937,18 +1963,17 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
} }
func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) { func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
db := database.GetDB() // Prefer retrieving along with client to reflect actual enabled state from inbound settings
var traffics []*xray.ClientTraffic t, client, err := s.GetClientByEmail(email)
err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error
if err != nil { if err != nil {
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err) logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
return nil, err return nil, err
} }
if len(traffics) > 0 { if t != nil && client != nil {
return traffics[0], nil // Ensure enable mirrors the client's current enable flag in settings
t.Enable = client.Enable
return t, nil
} }
return nil, nil return nil, nil
} }
@ -1983,6 +2008,12 @@ func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic,
logger.Debug(err) logger.Debug(err)
return nil, err return nil, err
} }
// Reconcile enable flag with client settings per email to avoid stale DB value
for i := range traffics {
if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil {
traffics[i].Enable = client.Enable
}
}
return traffics, err return traffics, err
} }
@ -2280,6 +2311,44 @@ func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, [
return validEmails, extraEmails, nil return validEmails, extraEmails, nil
} }
func (s *InboundService) syncWithSlaves(method string, path string, body io.Reader) {
serverService := MultiServerService{}
servers, err := serverService.GetServers()
if err != nil {
logger.Warning("Failed to get servers for syncing:", err)
return
}
for _, server := range servers {
if !server.Enable {
continue
}
url := fmt.Sprintf("http://%s:%d%s", server.Address, server.Port, path)
req, err := http.NewRequest(method, url, body)
if err != nil {
logger.Warningf("Failed to create request for server %s: %v", server.Name, err)
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Api-Key", server.APIKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
logger.Warningf("Failed to send request to server %s: %v", server.Name, err)
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
logger.Warningf("Failed to sync with server %s. Status: %s, Body: %s", server.Name, resp.Status, string(bodyBytes))
}
}
func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (bool, error) { func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (bool, error) {
oldInbound, err := s.GetInbound(inboundId) oldInbound, err := s.GetInbound(inboundId)
if err != nil { if err != nil {
@ -2371,4 +2440,5 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
} }
return needRestart, db.Save(oldInbound).Error return needRestart, db.Save(oldInbound).Error
} }

View file

@ -0,0 +1,72 @@
package service
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"x-ui/database"
"x-ui/database/model"
"github.com/stretchr/testify/assert"
)
func TestInboundServiceSync(t *testing.T) {
setup()
defer teardown()
// Mock server to simulate a slave
var receivedApiKey string
var receivedBody []byte
mockSlave := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedApiKey = r.Header.Get("Api-Key")
receivedBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
defer mockSlave.Close()
// Add the mock slave to the database
multiServerService := MultiServerService{}
mockSlaveURL, _ := url.Parse(mockSlave.URL)
mockSlavePort, _ := strconv.Atoi(mockSlaveURL.Port())
slaveServer := &model.Server{
Name: "mock-slave",
Address: mockSlaveURL.Hostname(),
Port: mockSlavePort,
APIKey: "slave-api-key",
Enable: true,
}
multiServerService.AddServer(slaveServer)
// Create a test inbound and client
inboundService := InboundService{}
db := database.GetDB()
testInbound := &model.Inbound{
UserId: 1,
Remark: "test-inbound",
Enable: true,
Settings: `{"clients":[]}`,
}
db.Create(testInbound)
clientData := model.Client{
Email: "test@example.com",
ID: "test-id",
}
clientBytes, _ := json.Marshal([]model.Client{clientData})
inboundData := &model.Inbound{
Id: testInbound.Id,
Settings: string(clientBytes),
}
// Test AddInboundClient sync
inboundService.AddInboundClient(inboundData)
assert.Equal(t, "slave-api-key", receivedApiKey)
var receivedInbound model.Inbound
json.Unmarshal(receivedBody, &receivedInbound)
assert.Equal(t, 1, receivedInbound.Id)
}

View file

@ -0,0 +1,37 @@
package service
import (
"x-ui/database"
"x-ui/database/model"
)
type MultiServerService struct{}
func (s *MultiServerService) GetServers() ([]*model.Server, error) {
db := database.GetDB()
var servers []*model.Server
err := db.Find(&servers).Error
return servers, err
}
func (s *MultiServerService) GetServer(id int) (*model.Server, error) {
db := database.GetDB()
var server model.Server
err := db.First(&server, id).Error
return &server, err
}
func (s *MultiServerService) AddServer(server *model.Server) error {
db := database.GetDB()
return db.Create(server).Error
}
func (s *MultiServerService) UpdateServer(server *model.Server) error {
db := database.GetDB()
return db.Save(server).Error
}
func (s *MultiServerService) DeleteServer(id int) error {
db := database.GetDB()
return db.Delete(&model.Server{}, id).Error
}

View file

@ -0,0 +1,63 @@
package service
import (
"os"
"testing"
"x-ui/database"
"x-ui/database/model"
"github.com/stretchr/testify/assert"
)
func setup() {
dbPath := "test.db"
os.Remove(dbPath)
database.InitDB(dbPath)
}
func teardown() {
db, _ := database.GetDB().DB()
db.Close()
os.Remove("test.db")
}
func TestMultiServerService(t *testing.T) {
setup()
defer teardown()
service := MultiServerService{}
// Test AddServer
server := &model.Server{
Name: "test-server",
Address: "127.0.0.1",
Port: 54321,
APIKey: "test-key",
Enable: true,
}
err := service.AddServer(server)
assert.NoError(t, err)
// Test GetServer
retrievedServer, err := service.GetServer(server.Id)
assert.NoError(t, err)
assert.Equal(t, server.Name, retrievedServer.Name)
// Test GetServers
servers, err := service.GetServers()
assert.NoError(t, err)
assert.Len(t, servers, 1)
// Test UpdateServer
retrievedServer.Name = "updated-server"
err = service.UpdateServer(retrievedServer)
assert.NoError(t, err)
updatedServer, _ := service.GetServer(server.Id)
assert.Equal(t, "updated-server", updatedServer.Name)
// Test DeleteServer
err = service.DeleteServer(server.Id)
assert.NoError(t, err)
_, err = service.GetServer(server.Id)
assert.Error(t, err)
}

View file

@ -16,6 +16,7 @@ import (
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"x-ui/config" "x-ui/config"
@ -98,6 +99,79 @@ type ServerService struct {
cachedIPv4 string cachedIPv4 string
cachedIPv6 string cachedIPv6 string
noIPv6 bool noIPv6 bool
mu sync.Mutex
lastCPUTimes cpu.TimesStat
hasLastCPUSample bool
emaCPU float64
cpuHistory []CPUSample
cachedCpuSpeedMhz float64
lastCpuInfoAttempt time.Time
}
// AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds over recent data.
func (s *ServerService) AggregateCpuHistory(bucketSeconds int, maxPoints int) []map[string]any {
if bucketSeconds <= 0 || maxPoints <= 0 {
return nil
}
cutoff := time.Now().Add(-time.Duration(bucketSeconds*maxPoints) * time.Second).Unix()
s.mu.Lock()
// find start index (history sorted ascending)
hist := s.cpuHistory
// binary-ish scan (simple linear from end since size capped ~10800 is fine)
startIdx := 0
for i := len(hist) - 1; i >= 0; i-- {
if hist[i].T < cutoff {
startIdx = i + 1
break
}
}
if startIdx >= len(hist) {
s.mu.Unlock()
return []map[string]any{}
}
slice := hist[startIdx:]
// copy for unlock
tmp := make([]CPUSample, len(slice))
copy(tmp, slice)
s.mu.Unlock()
if len(tmp) == 0 {
return []map[string]any{}
}
var out []map[string]any
var acc []float64
bSize := int64(bucketSeconds)
curBucket := (tmp[0].T / bSize) * bSize
flush := func(ts int64) {
if len(acc) == 0 {
return
}
sum := 0.0
for _, v := range acc {
sum += v
}
avg := sum / float64(len(acc))
out = append(out, map[string]any{"t": ts, "cpu": avg})
acc = acc[:0]
}
for _, p := range tmp {
b := (p.T / bSize) * bSize
if b != curBucket {
flush(curBucket)
curBucket = b
}
acc = append(acc, p.Cpu)
}
flush(curBucket)
if len(out) > maxPoints {
out = out[len(out)-maxPoints:]
}
return out
}
// CPUSample single CPU utilization sample
type CPUSample struct {
T int64 `json:"t"` // unix seconds
Cpu float64 `json:"cpu"` // percent 0..100
} }
func getPublicIP(url string) string { func getPublicIP(url string) string {
@ -139,11 +213,11 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
} }
// CPU stats // CPU stats
percents, err := cpu.Percent(0, false) util, err := s.sampleCPUUtilization()
if err != nil { if err != nil {
logger.Warning("get cpu percent failed:", err) logger.Warning("get cpu percent failed:", err)
} else { } else {
status.Cpu = percents[0] status.Cpu = util
} }
status.CpuCores, err = cpu.Counts(false) status.CpuCores, err = cpu.Counts(false)
@ -153,14 +227,31 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.LogicalPro = runtime.NumCPU() status.LogicalPro = runtime.NumCPU()
if status.CpuSpeedMhz = s.cachedCpuSpeedMhz; s.cachedCpuSpeedMhz == 0 && time.Since(s.lastCpuInfoAttempt) > 5*time.Minute {
s.lastCpuInfoAttempt = time.Now()
done := make(chan struct{})
go func() {
defer close(done)
cpuInfos, err := cpu.Info() cpuInfos, err := cpu.Info()
if err != nil { if err != nil {
logger.Warning("get cpu info failed:", err) logger.Warning("get cpu info failed:", err)
} else if len(cpuInfos) > 0 { return
status.CpuSpeedMhz = cpuInfos[0].Mhz }
if len(cpuInfos) > 0 {
s.cachedCpuSpeedMhz = cpuInfos[0].Mhz
status.CpuSpeedMhz = s.cachedCpuSpeedMhz
} else { } else {
logger.Warning("could not find cpu info") logger.Warning("could not find cpu info")
} }
}()
select {
case <-done:
case <-time.After(1500 * time.Millisecond):
logger.Warning("cpu info query timed out; will retry later")
}
} else if s.cachedCpuSpeedMhz != 0 {
status.CpuSpeedMhz = s.cachedCpuSpeedMhz
}
// Uptime // Uptime
upTime, err := host.Uptime() upTime, err := host.Uptime()
@ -307,6 +398,103 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
return status return status
} }
func (s *ServerService) AppendCpuSample(t time.Time, v float64) {
const capacity = 9000 // ~5 hours @ 2s interval
s.mu.Lock()
defer s.mu.Unlock()
p := CPUSample{T: t.Unix(), Cpu: v}
if n := len(s.cpuHistory); n > 0 && s.cpuHistory[n-1].T == p.T {
s.cpuHistory[n-1] = p
} else {
s.cpuHistory = append(s.cpuHistory, p)
}
if len(s.cpuHistory) > capacity {
s.cpuHistory = s.cpuHistory[len(s.cpuHistory)-capacity:]
}
}
func (s *ServerService) sampleCPUUtilization() (float64, error) {
// Prefer native Windows API to avoid external deps for CPU percent
if runtime.GOOS == "windows" {
if pct, err := sys.CPUPercentRaw(); err == nil {
s.mu.Lock()
// Smooth with EMA
const alpha = 0.3
if s.emaCPU == 0 {
s.emaCPU = pct
} else {
s.emaCPU = alpha*pct + (1-alpha)*s.emaCPU
}
val := s.emaCPU
s.mu.Unlock()
return val, nil
}
// If native call fails, fall back to gopsutil times
}
// Read aggregate CPU times (all CPUs combined)
times, err := cpu.Times(false)
if err != nil {
return 0, err
}
if len(times) == 0 {
return 0, fmt.Errorf("no cpu times available")
}
cur := times[0]
s.mu.Lock()
defer s.mu.Unlock()
// If this is the first sample, initialize and return current EMA (0 by default)
if !s.hasLastCPUSample {
s.lastCPUTimes = cur
s.hasLastCPUSample = true
return s.emaCPU, nil
}
// Compute busy and total deltas
idleDelta := cur.Idle - s.lastCPUTimes.Idle
// Sum of busy deltas (exclude Idle)
busyDelta := (cur.User - s.lastCPUTimes.User) +
(cur.System - s.lastCPUTimes.System) +
(cur.Nice - s.lastCPUTimes.Nice) +
(cur.Iowait - s.lastCPUTimes.Iowait) +
(cur.Irq - s.lastCPUTimes.Irq) +
(cur.Softirq - s.lastCPUTimes.Softirq) +
(cur.Steal - s.lastCPUTimes.Steal) +
(cur.Guest - s.lastCPUTimes.Guest) +
(cur.GuestNice - s.lastCPUTimes.GuestNice)
totalDelta := busyDelta + idleDelta
// Update last sample for next time
s.lastCPUTimes = cur
// Guard against division by zero or negative deltas (e.g., counter resets)
if totalDelta <= 0 {
return s.emaCPU, nil
}
raw := 100.0 * (busyDelta / totalDelta)
if raw < 0 {
raw = 0
}
if raw > 100 {
raw = 100
}
// Exponential moving average to smooth spikes
const alpha = 0.3 // smoothing factor (0<alpha<=1). Higher = more responsive, lower = smoother
if s.emaCPU == 0 {
// Initialize EMA with the first real reading to avoid long warm-up from zero
s.emaCPU = raw
} else {
s.emaCPU = alpha*raw + (1-alpha)*s.emaCPU
}
return s.emaCPU, nil
}
func (s *ServerService) GetXrayVersions() ([]string, error) { func (s *ServerService) GetXrayVersions() ([]string, error) {
const ( const (
XrayURL = "https://api.github.com/repos/XTLS/Xray-core/releases" XrayURL = "https://api.github.com/repos/XTLS/Xray-core/releases"

View file

@ -180,6 +180,21 @@ func (s *SettingService) getSetting(key string) (*model.Setting, error) {
return setting, nil return setting, nil
} }
func (s *SettingService) GetAPIKey() (string, error) {
setting, err := s.getSetting("ApiKey")
if err != nil {
return "", err
}
if setting == nil {
return "", nil
}
return setting.Value, nil
}
func (s *SettingService) SetAPIKey(apiKey string) error {
return s.saveSetting("ApiKey", apiKey)
}
func (s *SettingService) saveSetting(key string, value string) error { func (s *SettingService) saveSetting(key string, value string) error {
setting, err := s.getSetting(key) setting, err := s.getSetting(key)
db := database.GetDB() db := database.GetDB()

View file

@ -548,6 +548,57 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
if len(dataArray) >= 2 && len(dataArray[1]) > 0 { if len(dataArray) >= 2 && len(dataArray[1]) > 0 {
email := dataArray[1] email := dataArray[1]
switch dataArray[0] { switch dataArray[0] {
case "get_clients_for_sub":
inboundId := dataArray[1]
inboundIdInt, err := strconv.Atoi(inboundId)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_sub_links")
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
inbound, _ := t.inboundService.GetInbound(inboundIdInt)
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB)
case "get_clients_for_individual":
inboundId := dataArray[1]
inboundIdInt, err := strconv.Atoi(inboundId)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_individual_links")
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
inbound, _ := t.inboundService.GetInbound(inboundIdInt)
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB)
case "get_clients_for_qr":
inboundId := dataArray[1]
inboundIdInt, err := strconv.Atoi(inboundId)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_qr_links")
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
inbound, _ := t.inboundService.GetInbound(inboundIdInt)
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB)
case "client_sub_links":
t.sendClientSubLinks(chatId, email)
return
case "client_individual_links":
t.sendClientIndividualLinks(chatId, email)
return
case "client_qr_links":
t.sendClientQRLinks(chatId, email)
return
case "client_get_usage": case "client_get_usage":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.messages.email", "Email=="+email)) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.messages.email", "Email=="+email))
t.searchClient(chatId, email) t.searchClient(chatId, email)
@ -1327,6 +1378,27 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
} }
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.allClients")) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.allClients"))
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds)
case "admin_client_sub_links":
inbounds, err := t.getInboundsFor("get_clients_for_sub")
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds)
case "admin_client_individual_links":
inbounds, err := t.getInboundsFor("get_clients_for_individual")
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds)
case "admin_client_qr_links":
inbounds, err := t.getInboundsFor("get_clients_for_qr")
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds)
} }
} }
@ -1927,6 +1999,11 @@ func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")),
), ),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("admin_client_sub_links")),
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("admin_client_individual_links")),
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("admin_client_qr_links")),
),
// TODOOOOOOOOOOOOOO: Add restart button here. // TODOOOOOOOOOOOOOO: Add restart button here.
) )
numericKeyboardClient := tu.InlineKeyboard( numericKeyboardClient := tu.InlineKeyboard(
@ -2073,7 +2150,10 @@ func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
"JSON URL:\r\n<code>" + subJsonURL + "</code>" "JSON URL:\r\n<code>" + subJsonURL + "</code>"
inlineKeyboard := tu.InlineKeyboard( inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow( tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links " + email)), tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links "+email)),
), ),
) )
t.SendMsgToTgbot(chatId, msg, inlineKeyboard) t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
@ -2459,6 +2539,74 @@ func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) {
return keyboard, nil return keyboard, nil
} }
// getInboundsFor builds an inline keyboard of inbounds where each button leads to a custom next action
// nextAction should be one of: get_clients_for_sub|get_clients_for_individual|get_clients_for_qr
func (t *Tgbot) getInboundsFor(nextAction string) (*telego.InlineKeyboardMarkup, error) {
inbounds, err := t.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("GetAllInbounds run failed:", err)
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
if len(inbounds) == 0 {
logger.Warning("No inbounds found")
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
var buttons []telego.InlineKeyboardButton
for _, inbound := range inbounds {
status := "❌"
if inbound.Enable {
status = "✅"
}
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", nextAction, inbound.Id))
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
}
cols := 1
if len(buttons) >= 6 {
cols = 2
}
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
return keyboard, nil
}
// getInboundClientsFor lists clients of an inbound with a specific action prefix to be appended with email
func (t *Tgbot) getInboundClientsFor(inboundID int, action string) (*telego.InlineKeyboardMarkup, error) {
inbound, err := t.inboundService.GetInbound(inboundID)
if err != nil {
logger.Warning("getInboundClientsFor run failed:", err)
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
clients, err := t.inboundService.GetClients(inbound)
var buttons []telego.InlineKeyboardButton
if err != nil {
logger.Warning("GetInboundClients run failed:", err)
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
} else {
if len(clients) > 0 {
for _, client := range clients {
buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery(action+" "+client.Email)))
}
} else {
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
}
}
cols := 0
if len(buttons) < 6 {
cols = 3
} else {
cols = 2
}
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
return keyboard, nil
}
func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) { func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
inbounds, err := t.inboundService.GetAllInbounds() inbounds, err := t.inboundService.GetAllInbounds()
if err != nil { if err != nil {

View file

@ -252,7 +252,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
g := engine.Group(basePath) g := engine.Group(basePath)
s.index = controller.NewIndexController(g) s.index = controller.NewIndexController(g)
s.server = controller.NewServerController(g) s.server = controller.NewMultiServerController(g)
s.panel = controller.NewXUIController(g) s.panel = controller.NewXUIController(g)
s.api = controller.NewAPIController(g) s.api = controller.NewAPIController(g)
@ -289,9 +289,6 @@ func (s *Server) startTask() {
// check client ips from log file every day // check client ips from log file every day
s.cron.AddJob("@daily", job.NewClearLogsJob()) s.cron.AddJob("@daily", job.NewClearLogsJob())
// Periodic traffic resets
logger.Info("Scheduling periodic traffic reset jobs")
{
// Inbound traffic reset jobs // Inbound traffic reset jobs
// Run once a day, midnight // Run once a day, midnight
s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily")) s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
@ -300,8 +297,6 @@ func (s *Server) startTask() {
// Run once a month, midnight, first of month // Run once a month, midnight, first of month
s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly")) s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly"))
}
// Make a traffic condition every day, 8:30 // Make a traffic condition every day, 8:30
var entry cron.EntryID var entry cron.EntryID
isTgbotenabled, err := s.settingService.GetTgbotEnabled() isTgbotenabled, err := s.settingService.GetTgbotEnabled()