diff --git a/database/db.go b/database/db.go index 6b579dd9..b33a0621 100644 --- a/database/db.go +++ b/database/db.go @@ -38,6 +38,8 @@ func initModels() error { &model.InboundClientIps{}, &xray.ClientTraffic{}, &model.HistoryOfSeeders{}, + &model.Node{}, + &model.InboundNodeMapping{}, } for _, model := range models { if err := db.AutoMigrate(model); err != nil { diff --git a/database/model/model.go b/database/model/model.go index 6225df52..07de9640 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -53,6 +53,8 @@ type Inbound struct { StreamSettings string `json:"streamSettings" form:"streamSettings"` Tag string `json:"tag" form:"tag" gorm:"unique"` Sniffing string `json:"sniffing" form:"sniffing"` + NodeId *int `json:"nodeId,omitempty" form:"-" gorm:"-"` // Node ID (not stored in Inbound table, from mapping) - DEPRECATED: kept only for backward compatibility with old clients, use NodeIds instead + NodeIds []int `json:"nodeIds,omitempty" form:"-" gorm:"-"` // Node IDs array (not stored in Inbound table, from mapping) - use this for multi-node support } // OutboundTraffics tracks traffic statistics for Xray outbound connections. @@ -122,3 +124,22 @@ type Client struct { CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp } + +// Node represents a worker node in multi-node architecture. +type Node struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + Name string `json:"name" form:"name"` // Node name/identifier + Address string `json:"address" form:"address"` // Node API address (e.g., "http://192.168.1.100:8080") + ApiKey string `json:"apiKey" form:"apiKey"` // API key for authentication + Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown + LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp +} + +// InboundNodeMapping maps inbounds to nodes in multi-node mode. +type InboundNodeMapping struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_inbound_node"` // Inbound ID + NodeId int `json:"nodeId" form:"nodeId" gorm:"uniqueIndex:idx_inbound_node"` // Node ID +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 198df198..53784309 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,4 +13,4 @@ services: XUI_ENABLE_FAIL2BAN: "true" tty: true network_mode: host - restart: unless-stopped + restart: unless-stopped \ No newline at end of file diff --git a/go.mod b/go.mod index 126a109b..475727d9 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,9 @@ module github.com/mhsanaei/3x-ui/v2 +// Local development - use local files instead of GitHub +// These replace directives ensure we use local code during development +// Remove these when changes are pushed to GitHub + go 1.25.5 require ( @@ -101,3 +105,8 @@ require ( gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect lukechampine.com/blake3 v1.4.1 // indirect ) + +// Local development - use local files instead of GitHub +// This ensures we use local code during development +// Remove this when changes are pushed to GitHub +replace github.com/mhsanaei/3x-ui/v2 => ./ diff --git a/logger/logger.go b/logger/logger.go index 7d26dcd0..4ab3b4cf 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -69,12 +69,19 @@ func initDefaultBackend() logging.Backend { includeTime = true } else { // Unix-like: Try syslog, fallback to stderr - if syslogBackend, err := logging.NewSyslogBackend(""); err != nil { - fmt.Fprintf(os.Stderr, "syslog backend disabled: %v\n", err) - backend = logging.NewLogBackend(os.Stderr, "", 0) - includeTime = os.Getppid() > 0 - } else { + // Try syslog with "x-ui" tag first + if syslogBackend, err := logging.NewSyslogBackend("x-ui"); err == nil { backend = syslogBackend + } else { + // Try with empty tag as fallback + if syslogBackend2, err2 := logging.NewSyslogBackend(""); err2 == nil { + backend = syslogBackend2 + } else { + // Syslog unavailable - use stderr (normal in containers/Docker) + // In containers, syslog is often not configured - this is normal and expected + backend = logging.NewLogBackend(os.Stderr, "", 0) + includeTime = os.Getppid() > 0 + } } } diff --git a/node/Dockerfile b/node/Dockerfile new file mode 100644 index 00000000..31a73050 --- /dev/null +++ b/node/Dockerfile @@ -0,0 +1,124 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +WORKDIR /build + +# Install build dependencies +RUN apk --no-cache add curl unzip + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build node service +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o node-service ./node/main.go + +# Download XRAY Core based on target architecture +# TARGETARCH is automatically set by Docker BuildKit +ARG TARGETARCH=amd64 +ARG TARGETOS=linux +RUN mkdir -p bin && \ + cd bin && \ + case ${TARGETARCH} in \ + amd64) \ + ARCH="64" \ + FNAME="amd64" \ + ;; \ + arm64) \ + ARCH="arm64-v8a" \ + FNAME="arm64" \ + ;; \ + arm) \ + ARCH="arm32-v7a" \ + FNAME="arm32" \ + ;; \ + armv6) \ + ARCH="arm32-v6" \ + FNAME="armv6" \ + ;; \ + 386) \ + ARCH="32" \ + FNAME="i386" \ + ;; \ + *) \ + ARCH="64" \ + FNAME="amd64" \ + ;; \ + esac && \ + echo "Downloading Xray for ${TARGETARCH} (ARCH=${ARCH}, FNAME=${FNAME})" && \ + curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v25.12.8/Xray-linux-${ARCH}.zip" && \ + echo "Unzipping..." && \ + unzip -q "Xray-linux-${ARCH}.zip" && \ + echo "Files after unzip:" && \ + ls -la && \ + echo "Removing zip and old data files..." && \ + rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat && \ + echo "Renaming xray to xray-linux-${FNAME}..." && \ + mv xray "xray-linux-${FNAME}" && \ + chmod +x "xray-linux-${FNAME}" && \ + echo "Verifying xray binary:" && \ + ls -lh "xray-linux-${FNAME}" && \ + test -f "xray-linux-${FNAME}" && echo "✓ xray-linux-${FNAME} exists" && \ + echo "Downloading geo files..." && \ + curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat && \ + curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat && \ + curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat && \ + curl -sfLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat && \ + curl -sfLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat && \ + curl -sfLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat && \ + echo "Final files in bin:" && \ + ls -lah && \ + echo "File sizes:" && \ + du -h * && \ + cd .. && \ + echo "Verifying files in /build/bin:" && \ + ls -lah /build/bin/ + +# Runtime stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /build/node-service . + +# Copy XRAY binary and data files +# Use wildcard to copy all files from bin directory +COPY --from=builder /build/bin/ ./bin/ + +# Verify files were copied and make executable +RUN echo "Contents of /app/bin after COPY:" && \ + ls -la ./bin/ && \ + echo "Looking for xray binary..." && \ + if [ -f ./bin/xray-linux-amd64 ]; then \ + chmod +x ./bin/xray-linux-amd64 && \ + echo "✓ Found and made executable: xray-linux-amd64"; \ + elif [ -f ./bin/xray ]; then \ + chmod +x ./bin/xray && \ + mv ./bin/xray ./bin/xray-linux-amd64 && \ + echo "✓ Found xray, renamed to xray-linux-amd64"; \ + else \ + echo "✗ ERROR: No xray binary found!" && \ + echo "All files in bin directory:" && \ + find ./bin -type f -o -type l && \ + exit 1; \ + fi + +# Create directories for config and logs +RUN mkdir -p /app/config /app/logs + +# Set environment variables for paths +ENV XUI_BIN_FOLDER=/app/bin +ENV XUI_LOG_FOLDER=/app/logs + +# Expose API port +EXPOSE 8080 + +# Run node service +# The API key will be read from NODE_API_KEY environment variable +CMD ["./node-service", "-port", "8080"] diff --git a/node/README.md b/node/README.md new file mode 100644 index 00000000..a672ee7b --- /dev/null +++ b/node/README.md @@ -0,0 +1,79 @@ +# 3x-ui Node Service + +Node service (worker) for 3x-ui multi-node architecture. + +## Description + +This service runs on separate servers and manages XRAY Core instances. The 3x-ui panel (master) sends configurations to nodes via REST API. + +## Features + +- REST API for XRAY Core management +- Apply configurations from the panel +- Reload XRAY without stopping the container +- Status and health checks + +## API Endpoints + +### `GET /health` +Health check endpoint (no authentication required) + +### `POST /api/v1/apply` +Apply new XRAY configuration +- **Headers**: `Authorization: Bearer ` +- **Body**: XRAY JSON configuration + +### `POST /api/v1/reload` +Reload XRAY +- **Headers**: `Authorization: Bearer ` + +### `POST /api/v1/force-reload` +Force reload XRAY (stops and restarts) +- **Headers**: `Authorization: Bearer ` + +### `GET /api/v1/status` +Get XRAY status +- **Headers**: `Authorization: Bearer ` + +### `GET /api/v1/stats` +Get traffic statistics and online clients +- **Headers**: `Authorization: Bearer ` +- **Query Parameters**: `reset=true` to reset statistics after reading + +## Running + +### Docker Compose + +```bash +cd node +NODE_API_KEY=your-secure-api-key docker-compose up -d --build +``` + +**Note:** XRAY Core is automatically downloaded during Docker image build for your architecture. Docker BuildKit automatically detects the host architecture. To explicitly specify the architecture, use: + +```bash +DOCKER_BUILDKIT=1 docker build --build-arg TARGETARCH=arm64 -t 3x-ui-node -f node/Dockerfile .. +``` + +### Manual + +```bash +go run node/main.go -port 8080 -api-key your-secure-api-key +``` + +## Environment Variables + +- `NODE_API_KEY` - API key for authentication (required) + +## Structure + +``` +node/ +├── main.go # Entry point +├── api/ +│ └── server.go # REST API server +├── xray/ +│ └── manager.go # XRAY process management +├── Dockerfile # Docker image +└── docker-compose.yml +``` diff --git a/node/api/server.go b/node/api/server.go new file mode 100644 index 00000000..11fa93c4 --- /dev/null +++ b/node/api/server.go @@ -0,0 +1,177 @@ +// Package api provides REST API endpoints for the node service. +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/node/xray" + "github.com/gin-gonic/gin" +) + +// Server provides REST API for managing the node. +type Server struct { + port int + apiKey string + xrayManager *xray.Manager + httpServer *http.Server +} + +// NewServer creates a new API server instance. +func NewServer(port int, apiKey string, xrayManager *xray.Manager) *Server { + return &Server{ + port: port, + apiKey: apiKey, + xrayManager: xrayManager, + } +} + +// Start starts the HTTP server. +func (s *Server) Start() error { + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Recovery()) + router.Use(s.authMiddleware()) + + // Health check endpoint (no auth required) + router.GET("/health", s.health) + + // API endpoints (require auth) + api := router.Group("/api/v1") + { + api.POST("/apply-config", s.applyConfig) + api.POST("/reload", s.reload) + api.POST("/force-reload", s.forceReload) + api.GET("/status", s.status) + api.GET("/stats", s.stats) + } + + s.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", s.port), + Handler: router, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + logger.Infof("API server listening on port %d", s.port) + return s.httpServer.ListenAndServe() +} + +// Stop stops the HTTP server. +func (s *Server) Stop() error { + if s.httpServer == nil { + return nil + } + return s.httpServer.Close() +} + +// authMiddleware validates API key from Authorization header. +func (s *Server) authMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Skip auth for health endpoint + if c.Request.URL.Path == "/health" { + c.Next() + return + } + + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"}) + c.Abort() + return + } + + // Support both "Bearer " and direct key + apiKey := authHeader + if len(authHeader) > 7 && authHeader[:7] == "Bearer " { + apiKey = authHeader[7:] + } + + if apiKey != s.apiKey { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"}) + c.Abort() + return + } + + c.Next() + } +} + +// health returns the health status of the node. +func (s *Server) health(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "service": "3x-ui-node", + }) +} + +// applyConfig applies a new XRAY configuration. +func (s *Server) applyConfig(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"}) + return + } + + // Validate JSON + var configJSON json.RawMessage + if err := json.Unmarshal(body, &configJSON); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"}) + return + } + + if err := s.xrayManager.ApplyConfig(body); err != nil { + logger.Errorf("Failed to apply config: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Configuration applied successfully"}) +} + +// reload reloads XRAY configuration. +func (s *Server) reload(c *gin.Context) { + if err := s.xrayManager.Reload(); err != nil { + logger.Errorf("Failed to reload: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "XRAY reloaded successfully"}) +} + +// forceReload forcefully reloads XRAY even if it's hung or not running. +func (s *Server) forceReload(c *gin.Context) { + if err := s.xrayManager.ForceReload(); err != nil { + logger.Errorf("Failed to force reload: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "XRAY force reloaded successfully"}) +} + +// status returns the current status of XRAY. +func (s *Server) status(c *gin.Context) { + status := s.xrayManager.GetStatus() + c.JSON(http.StatusOK, status) +} + +// stats returns traffic and online clients statistics from XRAY. +func (s *Server) stats(c *gin.Context) { + // Get reset parameter (default: false) + reset := c.DefaultQuery("reset", "false") == "true" + + stats, err := s.xrayManager.GetStats(reset) + if err != nil { + logger.Errorf("Failed to get stats: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} diff --git a/node/docker-compose.yml b/node/docker-compose.yml new file mode 100644 index 00000000..d72d6407 --- /dev/null +++ b/node/docker-compose.yml @@ -0,0 +1,24 @@ +services: + node: + build: + context: .. + dockerfile: node/Dockerfile + container_name: 3x-ui-node + restart: unless-stopped + environment: +# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key} + - NODE_API_KEY=test-key + ports: + - "8080:8080" + - "44000:44000" + volumes: + - ./bin/config.json:/app/bin/config.json + - ./logs:/app/logs + # Note: config.json is mounted directly for persistence + # If the file doesn't exist, it will be created when XRAY config is first applied + networks: + - xray-network + +networks: + xray-network: + driver: bridge diff --git a/node/main.go b/node/main.go new file mode 100644 index 00000000..617981f2 --- /dev/null +++ b/node/main.go @@ -0,0 +1,52 @@ +// Package main is the entry point for the 3x-ui node service (worker). +// This service runs XRAY Core and provides a REST API for the master panel to manage it. +package main + +import ( + "flag" + "log" + "os" + "os/signal" + "syscall" + + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/node/api" + "github.com/mhsanaei/3x-ui/v2/node/xray" + "github.com/op/go-logging" +) + +func main() { + var port int + var apiKey string + flag.IntVar(&port, "port", 8080, "API server port") + flag.StringVar(&apiKey, "api-key", "", "API key for authentication (required)") + flag.Parse() + + // Check environment variable if flag is not provided + if apiKey == "" { + apiKey = os.Getenv("NODE_API_KEY") + } + + if apiKey == "" { + log.Fatal("API key is required. Set NODE_API_KEY environment variable or use -api-key flag") + } + + logger.InitLogger(logging.INFO) + + xrayManager := xray.NewManager() + server := api.NewServer(port, apiKey, xrayManager) + + log.Printf("Starting 3x-ui Node Service on port %d", port) + if err := server.Start(); err != nil { + log.Fatalf("Failed to start server: %v", err) + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + + log.Println("Shutting down...") + xrayManager.Stop() + server.Stop() + log.Println("Shutdown complete") +} diff --git a/node/xray/manager.go b/node/xray/manager.go new file mode 100644 index 00000000..a8522275 --- /dev/null +++ b/node/xray/manager.go @@ -0,0 +1,471 @@ +// Package xray provides XRAY Core management for the node service. +package xray + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/util/json_util" + "github.com/mhsanaei/3x-ui/v2/xray" +) + +// NodeStats represents traffic and online clients statistics from a node. +type NodeStats struct { + Traffic []*xray.Traffic `json:"traffic"` + ClientTraffic []*xray.ClientTraffic `json:"clientTraffic"` + OnlineClients []string `json:"onlineClients"` +} + +// Manager manages the XRAY Core process lifecycle. +type Manager struct { + process *xray.Process + lock sync.Mutex + config *xray.Config +} + +// NewManager creates a new XRAY manager instance. +func NewManager() *Manager { + m := &Manager{} + // Download geo files if missing + m.downloadGeoFiles() + // Try to load config from file on startup + m.LoadConfigFromFile() + return m +} + +// downloadGeoFiles downloads geo data files if they are missing. +// These files are required for routing rules that use geoip/geosite matching. +func (m *Manager) downloadGeoFiles() { + // Possible bin folder paths (in order of priority) + binPaths := []string{ + "bin", + "/app/bin", + "./bin", + } + + var binPath string + for _, path := range binPaths { + if _, err := os.Stat(path); err == nil { + binPath = path + break + } + } + + if binPath == "" { + logger.Debug("No bin folder found, skipping geo files download") + return + } + + // List of geo files to download + geoFiles := []struct { + URL string + FileName string + }{ + {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.dat"}, + {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite.dat"}, + {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", "geoip_IR.dat"}, + {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", "geosite_IR.dat"}, + {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip_RU.dat"}, + {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"}, + } + + downloadFile := func(url, destPath string) error { + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("failed to download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status: %d", resp.StatusCode) + } + + file, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil + } + + for _, file := range geoFiles { + destPath := filepath.Join(binPath, file.FileName) + + // Check if file already exists + if _, err := os.Stat(destPath); err == nil { + logger.Debugf("Geo file %s already exists, skipping download", file.FileName) + continue + } + + logger.Infof("Downloading geo file: %s", file.FileName) + if err := downloadFile(file.URL, destPath); err != nil { + logger.Warningf("Failed to download %s: %v", file.FileName, err) + } else { + logger.Infof("Successfully downloaded %s", file.FileName) + } + } +} + +// LoadConfigFromFile attempts to load XRAY configuration from config.json file. +// It checks multiple possible locations: bin/config.json, config/config.json, and ./config.json +func (m *Manager) LoadConfigFromFile() error { + // Possible config file paths (in order of priority) + configPaths := []string{ + "bin/config.json", + "config/config.json", + "./config.json", + "/app/bin/config.json", + "/app/config/config.json", + } + + var configData []byte + var configPath string + + // Try each path until we find a valid config file + for _, path := range configPaths { + if _, statErr := os.Stat(path); statErr == nil { + var readErr error + configData, readErr = os.ReadFile(path) + if readErr == nil { + configPath = path + break + } + } + } + + // If no config file found, that's okay - node will wait for config from panel + if configPath == "" { + logger.Debug("No config.json found, node will wait for configuration from panel") + return nil + } + + // Validate JSON + var configJSON json.RawMessage + if err := json.Unmarshal(configData, &configJSON); err != nil { + logger.Warningf("Config file %s contains invalid JSON: %v", configPath, err) + return fmt.Errorf("invalid JSON in config file: %w", err) + } + + // Parse full config + var config xray.Config + if err := json.Unmarshal(configData, &config); err != nil { + logger.Warningf("Failed to parse config from %s: %v", configPath, err) + return fmt.Errorf("failed to parse config: %w", err) + } + + // Check if API inbound exists, if not add it + hasAPIInbound := false + for _, inbound := range config.InboundConfigs { + if inbound.Tag == "api" { + hasAPIInbound = true + break + } + } + + // If no API inbound found, add a default one + if !hasAPIInbound { + logger.Debug("No API inbound found in config, adding default API inbound") + apiInbound := xray.InboundConfig{ + Tag: "api", + Port: 62789, // Default API port + Protocol: "tunnel", + Listen: json_util.RawMessage(`"127.0.0.1"`), + Settings: json_util.RawMessage(`{"address":"127.0.0.1"}`), + } + // Add API inbound at the beginning + config.InboundConfigs = append([]xray.InboundConfig{apiInbound}, config.InboundConfigs...) + // Update configData with the new inbound + configData, _ = json.MarshalIndent(&config, "", " ") + } + + // Check if config has inbounds (after adding API inbound) + if len(config.InboundConfigs) == 0 { + logger.Debug("Config file found but no inbounds configured, skipping XRAY start") + return nil + } + + // Apply the loaded config (this will start XRAY) + logger.Infof("Loading XRAY configuration from %s", configPath) + if err := m.ApplyConfig(configData); err != nil { + logger.Errorf("Failed to apply config from file: %v", err) + return fmt.Errorf("failed to apply config: %w", err) + } + + logger.Info("XRAY started successfully from config file") + return nil +} + +// IsRunning returns true if XRAY is currently running. +func (m *Manager) IsRunning() bool { + m.lock.Lock() + defer m.lock.Unlock() + return m.process != nil && m.process.IsRunning() +} + +// GetStatus returns the current status of XRAY. +func (m *Manager) GetStatus() map[string]interface{} { + m.lock.Lock() + defer m.lock.Unlock() + + status := map[string]interface{}{ + "running": m.process != nil && m.process.IsRunning(), + "version": "Unknown", + "uptime": 0, + } + + if m.process != nil && m.process.IsRunning() { + status["version"] = m.process.GetVersion() + status["uptime"] = m.process.GetUptime() + } + + return status +} + +// ApplyConfig applies a new XRAY configuration and restarts if needed. +func (m *Manager) ApplyConfig(configJSON []byte) error { + m.lock.Lock() + defer m.lock.Unlock() + + var newConfig xray.Config + if err := json.Unmarshal(configJSON, &newConfig); err != nil { + return fmt.Errorf("failed to parse config: %w", err) + } + + // If XRAY is running and config is the same, skip restart + if m.process != nil && m.process.IsRunning() { + oldConfig := m.process.GetConfig() + if oldConfig != nil && oldConfig.Equals(&newConfig) { + logger.Info("Config unchanged, skipping restart") + return nil + } + // Stop existing process + if err := m.process.Stop(); err != nil { + logger.Warningf("Failed to stop existing XRAY: %v", err) + } + } + + // Start new process with new config + m.config = &newConfig + m.process = xray.NewProcess(&newConfig) + if err := m.process.Start(); err != nil { + return fmt.Errorf("failed to start XRAY: %w", err) + } + + logger.Info("XRAY configuration applied successfully") + return nil +} + +// Reload reloads XRAY configuration without full restart (if supported). +// Falls back to restart if reload is not available. +func (m *Manager) Reload() error { + m.lock.Lock() + defer m.lock.Unlock() + + if m.process == nil || !m.process.IsRunning() { + return errors.New("XRAY is not running") + } + + // XRAY doesn't support hot reload, so we need to restart + // Save current config + if m.config == nil { + return errors.New("no config to reload") + } + + // Stop and restart + if err := m.process.Stop(); err != nil { + return fmt.Errorf("failed to stop XRAY: %w", err) + } + + m.process = xray.NewProcess(m.config) + if err := m.process.Start(); err != nil { + return fmt.Errorf("failed to restart XRAY: %w", err) + } + + logger.Info("XRAY reloaded successfully") + return nil +} + +// ForceReload forcefully reloads XRAY even if it's not running or hung. +// It stops XRAY if running, loads config from file if available, and restarts. +func (m *Manager) ForceReload() error { + m.lock.Lock() + defer m.lock.Unlock() + + // Stop XRAY if it's running (even if hung) + if m.process != nil { + // Try to stop gracefully, but don't fail if it's hung + _ = m.process.Stop() + // Give it a moment to stop + time.Sleep(500 * time.Millisecond) + // Force kill if still running + if m.process.IsRunning() { + logger.Warning("XRAY process appears hung, forcing stop") + // Process will be cleaned up by finalizer or on next start + } + m.process = nil + } + + // Try to load config from file first (if available) + configPaths := []string{ + "bin/config.json", + "config/config.json", + "./config.json", + "/app/bin/config.json", + "/app/config/config.json", + } + + var configData []byte + var configPath string + + // Find config file + for _, path := range configPaths { + if _, statErr := os.Stat(path); statErr == nil { + var readErr error + configData, readErr = os.ReadFile(path) + if readErr == nil { + configPath = path + break + } + } + } + + // If config file found, try to use it + if configPath != "" { + var config xray.Config + if err := json.Unmarshal(configData, &config); err == nil { + // Check if config has inbounds + if len(config.InboundConfigs) > 0 { + // Check if API inbound exists + hasAPIInbound := false + for _, inbound := range config.InboundConfigs { + if inbound.Tag == "api" { + hasAPIInbound = true + break + } + } + + // Add API inbound if missing + if !hasAPIInbound { + apiInbound := xray.InboundConfig{ + Tag: "api", + Port: 62789, + Protocol: "tunnel", + Listen: json_util.RawMessage(`"127.0.0.1"`), + Settings: json_util.RawMessage(`{"address":"127.0.0.1"}`), + } + config.InboundConfigs = append([]xray.InboundConfig{apiInbound}, config.InboundConfigs...) + configData, _ = json.MarshalIndent(&config, "", " ") + } + + // Apply config from file + m.config = &config + m.process = xray.NewProcess(&config) + if err := m.process.Start(); err == nil { + logger.Infof("XRAY force reloaded successfully from config file %s", configPath) + return nil + } + } + } + // If loading from file failed, continue with saved config + } + + // If no config file, try to use saved config + if m.config == nil { + return errors.New("no config available to reload") + } + + // Restart with saved config + m.process = xray.NewProcess(m.config) + if err := m.process.Start(); err != nil { + return fmt.Errorf("failed to restart XRAY: %w", err) + } + + logger.Info("XRAY force reloaded successfully") + return nil +} + +// Stop stops the XRAY process. +func (m *Manager) Stop() error { + m.lock.Lock() + defer m.lock.Unlock() + + if m.process == nil || !m.process.IsRunning() { + return nil + } + + return m.process.Stop() +} + +// GetStats returns traffic and online clients statistics from XRAY. +func (m *Manager) GetStats(reset bool) (*NodeStats, error) { + m.lock.Lock() + defer m.lock.Unlock() + + if m.process == nil || !m.process.IsRunning() { + return nil, errors.New("XRAY is not running") + } + + // Get API port from process + apiPort := m.process.GetAPIPort() + if apiPort == 0 { + return nil, errors.New("XRAY API port is not available") + } + + // Create XrayAPI instance and initialize + xrayAPI := &xray.XrayAPI{} + if err := xrayAPI.Init(apiPort); err != nil { + return nil, fmt.Errorf("failed to initialize XrayAPI: %w", err) + } + defer xrayAPI.Close() + + // Get traffic statistics + traffics, clientTraffics, err := xrayAPI.GetTraffic(reset) + if err != nil { + return nil, fmt.Errorf("failed to get traffic: %w", err) + } + + // Get online clients from process + onlineClients := m.process.GetOnlineClients() + + // Also check online clients from traffic (clients with traffic > 0) + onlineFromTraffic := make(map[string]bool) + for _, ct := range clientTraffics { + if ct.Up+ct.Down > 0 { + onlineFromTraffic[ct.Email] = true + } + } + + // Merge online clients + onlineSet := make(map[string]bool) + for _, email := range onlineClients { + onlineSet[email] = true + } + for email := range onlineFromTraffic { + onlineSet[email] = true + } + + onlineList := make([]string, 0, len(onlineSet)) + for email := range onlineSet { + onlineList = append(onlineList, email) + } + + return &NodeStats{ + Traffic: traffics, + ClientTraffic: clientTraffics, + OnlineClients: onlineList, + }, nil +} diff --git a/sub/subController.go b/sub/subController.go index ec574d6e..a219dd63 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/mhsanaei/3x-ui/v2/config" + service "github.com/mhsanaei/3x-ui/v2/web/service" "github.com/gin-gonic/gin" ) @@ -40,6 +41,8 @@ func NewSUBController( subTitle string, ) *SUBController { sub := NewSubService(showInfo, rModel) + // Initialize NodeService for multi-node support + sub.nodeService = service.NodeService{} a := &SUBController{ subTitle: subTitle, subPath: subPath, diff --git a/sub/subService.go b/sub/subService.go index e046ebb4..ab746a26 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -28,6 +28,7 @@ type SubService struct { datepicker string inboundService service.InboundService settingService service.SettingService + nodeService service.NodeService } // NewSubService creates a new subscription service with the given configuration. @@ -77,7 +78,14 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C for _, client := range clients { if client.Enable && client.SubID == subId { link := s.getLink(inbound, client.Email) - result = append(result, link) + // Split link by newline to handle multiple links (for multiple nodes) + linkLines := strings.Split(link, "\n") + for _, linkLine := range linkLines { + linkLine = strings.TrimSpace(linkLine) + if linkLine != "" { + result = append(result, linkLine) + } + } ct := s.getClientTraffics(inbound.ClientStats, client.Email) clientTraffics = append(clientTraffics, ct) if ct.LastOnline > lastOnline { @@ -179,78 +187,99 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.VMESS { return "" } - var address string - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - address = s.address - } else { - address = inbound.Listen + + // Get all nodes for this inbound + var nodeAddresses []string + multiMode, _ := s.settingService.GetMultiNodeMode() + if multiMode { + nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) + if err == nil && len(nodes) > 0 { + // Extract addresses from all nodes + for _, node := range nodes { + nodeAddr := s.extractNodeHost(node.Address) + if nodeAddr != "" { + nodeAddresses = append(nodeAddresses, nodeAddr) + } + } + } } - obj := map[string]any{ + + // Fallback to default logic if no nodes found + var defaultAddress string + if len(nodeAddresses) == 0 { + if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { + defaultAddress = s.address + } else { + defaultAddress = inbound.Listen + } + nodeAddresses = []string{defaultAddress} + } + // Base object template (address will be set per node) + baseObj := map[string]any{ "v": "2", - "add": address, "port": inbound.Port, "type": "none", } var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) network, _ := stream["network"].(string) - obj["net"] = network + baseObj["net"] = network switch network { case "tcp": tcp, _ := stream["tcpSettings"].(map[string]any) header, _ := tcp["header"].(map[string]any) typeStr, _ := header["type"].(string) - obj["type"] = typeStr + baseObj["type"] = typeStr if typeStr == "http" { request := header["request"].(map[string]any) requestPath, _ := request["path"].([]any) - obj["path"] = requestPath[0].(string) + baseObj["path"] = requestPath[0].(string) headers, _ := request["headers"].(map[string]any) - obj["host"] = searchHost(headers) + baseObj["host"] = searchHost(headers) } case "kcp": kcp, _ := stream["kcpSettings"].(map[string]any) header, _ := kcp["header"].(map[string]any) - obj["type"], _ = header["type"].(string) - obj["path"], _ = kcp["seed"].(string) + baseObj["type"], _ = header["type"].(string) + baseObj["path"], _ = kcp["seed"].(string) case "ws": ws, _ := stream["wsSettings"].(map[string]any) - obj["path"] = ws["path"].(string) + baseObj["path"] = ws["path"].(string) if host, ok := ws["host"].(string); ok && len(host) > 0 { - obj["host"] = host + baseObj["host"] = host } else { headers, _ := ws["headers"].(map[string]any) - obj["host"] = searchHost(headers) + baseObj["host"] = searchHost(headers) } case "grpc": grpc, _ := stream["grpcSettings"].(map[string]any) - obj["path"] = grpc["serviceName"].(string) - obj["authority"] = grpc["authority"].(string) + baseObj["path"] = grpc["serviceName"].(string) + baseObj["authority"] = grpc["authority"].(string) if grpc["multiMode"].(bool) { - obj["type"] = "multi" + baseObj["type"] = "multi" } case "httpupgrade": httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) - obj["path"] = httpupgrade["path"].(string) + baseObj["path"] = httpupgrade["path"].(string) if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { - obj["host"] = host + baseObj["host"] = host } else { headers, _ := httpupgrade["headers"].(map[string]any) - obj["host"] = searchHost(headers) + baseObj["host"] = searchHost(headers) } case "xhttp": xhttp, _ := stream["xhttpSettings"].(map[string]any) - obj["path"] = xhttp["path"].(string) + baseObj["path"] = xhttp["path"].(string) if host, ok := xhttp["host"].(string); ok && len(host) > 0 { - obj["host"] = host + baseObj["host"] = host } else { headers, _ := xhttp["headers"].(map[string]any) - obj["host"] = searchHost(headers) + baseObj["host"] = searchHost(headers) } - obj["mode"] = xhttp["mode"].(string) + baseObj["mode"] = xhttp["mode"].(string) } security, _ := stream["security"].(string) - obj["tls"] = security + baseObj["tls"] = security if security == "tls" { tlsSetting, _ := stream["tlsSettings"].(map[string]any) alpns, _ := tlsSetting["alpn"].([]any) @@ -259,19 +288,19 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { for _, a := range alpns { alpn = append(alpn, a.(string)) } - obj["alpn"] = strings.Join(alpn, ",") + baseObj["alpn"] = strings.Join(alpn, ",") } if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { - obj["sni"], _ = sniValue.(string) + baseObj["sni"], _ = sniValue.(string) } tlsSettings, _ := searchKey(tlsSetting, "settings") if tlsSetting != nil { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { - obj["fp"], _ = fpValue.(string) + baseObj["fp"], _ = fpValue.(string) } if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { - obj["allowInsecure"], _ = insecure.(bool) + baseObj["allowInsecure"], _ = insecure.(bool) } } } @@ -284,18 +313,22 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { break } } - obj["id"] = clients[clientIndex].ID - obj["scy"] = clients[clientIndex].Security + baseObj["id"] = clients[clientIndex].ID + baseObj["scy"] = clients[clientIndex].Security externalProxies, _ := stream["externalProxy"].([]any) + // Generate links for each node address (or external proxy) + links := "" + linkIndex := 0 + + // First, handle external proxies if any if len(externalProxies) > 0 { - links := "" - for index, externalProxy := range externalProxies { + for _, externalProxy := range externalProxies { ep, _ := externalProxy.(map[string]any) newSecurity, _ := ep["forceTls"].(string) newObj := map[string]any{} - for key, value := range obj { + for key, value := range baseObj { if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "allowInsecure")) { newObj[key] = value } @@ -307,32 +340,67 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { if newSecurity != "same" { newObj["tls"] = newSecurity } - if index > 0 { + if linkIndex > 0 { links += "\n" } jsonStr, _ := json.MarshalIndent(newObj, "", " ") links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + linkIndex++ } return links } - obj["ps"] = s.genRemark(inbound, email, "") + // Generate links for each node address + for _, nodeAddr := range nodeAddresses { + obj := make(map[string]any) + for k, v := range baseObj { + obj[k] = v + } + obj["add"] = nodeAddr + obj["ps"] = s.genRemark(inbound, email, "") - jsonStr, _ := json.MarshalIndent(obj, "", " ") - return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + if linkIndex > 0 { + links += "\n" + } + jsonStr, _ := json.MarshalIndent(obj, "", " ") + links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + linkIndex++ + } + + return links } func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { - var address string - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - address = s.address - } else { - address = inbound.Listen - } - if inbound.Protocol != model.VLESS { return "" } + + // Get all nodes for this inbound + var nodeAddresses []string + multiMode, _ := s.settingService.GetMultiNodeMode() + if multiMode { + nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) + if err == nil && len(nodes) > 0 { + // Extract addresses from all nodes + for _, node := range nodes { + nodeAddr := s.extractNodeHost(node.Address) + if nodeAddr != "" { + nodeAddresses = append(nodeAddresses, nodeAddr) + } + } + } + } + + // Fallback to default logic if no nodes found + var defaultAddress string + if len(nodeAddresses) == 0 { + if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { + defaultAddress = s.address + } else { + defaultAddress = inbound.Listen + } + nodeAddresses = []string{defaultAddress} + } var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -483,14 +551,24 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { externalProxies, _ := stream["externalProxy"].([]any) + // Generate links for each node address (or external proxy) + // Pre-allocate capacity based on external proxies or node addresses + var initialCapacity int + if len(externalProxies) > 0 { + initialCapacity = len(externalProxies) + } else { + initialCapacity = len(nodeAddresses) + } + links := make([]string, 0, initialCapacity) + + // First, handle external proxies if any if len(externalProxies) > 0 { - links := make([]string, 0, len(externalProxies)) for _, externalProxy := range externalProxies { ep, _ := externalProxy.(map[string]any) newSecurity, _ := ep["forceTls"].(string) dest, _ := ep["dest"].(string) - port := int(ep["port"].(float64)) - link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, port) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, epPort) if newSecurity != "same" { params["security"] = newSecurity @@ -516,31 +594,58 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { return strings.Join(links, "\n") } - link := fmt.Sprintf("vless://%s@%s:%d", uuid, address, port) - url, _ := url.Parse(link) - q := url.Query() + // Generate links for each node address + for _, nodeAddr := range nodeAddresses { + link := fmt.Sprintf("vless://%s@%s:%d", uuid, nodeAddr, port) + url, _ := url.Parse(link) + q := url.Query() - for k, v := range params { - q.Add(k, v) + for k, v := range params { + q.Add(k, v) + } + + // Set the new query values on the URL + url.RawQuery = q.Encode() + + url.Fragment = s.genRemark(inbound, email, "") + + links = append(links, url.String()) } - - // Set the new query values on the URL - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, "") - return url.String() + + return strings.Join(links, "\n") } func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string { - var address string - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - address = s.address - } else { - address = inbound.Listen - } if inbound.Protocol != model.Trojan { return "" } + + // Get all nodes for this inbound + var nodeAddresses []string + multiMode, _ := s.settingService.GetMultiNodeMode() + if multiMode { + nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) + if err == nil && len(nodes) > 0 { + // Extract addresses from all nodes + for _, node := range nodes { + nodeAddr := s.extractNodeHost(node.Address) + if nodeAddr != "" { + nodeAddresses = append(nodeAddresses, nodeAddr) + } + } + } + } + + // Fallback to default logic if no nodes found + var defaultAddress string + if len(nodeAddresses) == 0 { + if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { + defaultAddress = s.address + } else { + defaultAddress = inbound.Listen + } + nodeAddresses = []string{defaultAddress} + } var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -680,14 +785,18 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string externalProxies, _ := stream["externalProxy"].([]any) + // Generate links for each node address (or external proxy) + links := "" + linkIndex := 0 + + // First, handle external proxies if any if len(externalProxies) > 0 { - links := "" - for index, externalProxy := range externalProxies { + for _, externalProxy := range externalProxies { ep, _ := externalProxy.(map[string]any) newSecurity, _ := ep["forceTls"].(string) dest, _ := ep["dest"].(string) - port := int(ep["port"].(float64)) - link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, port) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, epPort) if newSecurity != "same" { params["security"] = newSecurity @@ -708,40 +817,71 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) - if index > 0 { + if linkIndex > 0 { links += "\n" } links += url.String() + linkIndex++ } return links } - link := fmt.Sprintf("trojan://%s@%s:%d", password, address, port) + // Generate links for each node address + for _, nodeAddr := range nodeAddresses { + link := fmt.Sprintf("trojan://%s@%s:%d", password, nodeAddr, port) + url, _ := url.Parse(link) + q := url.Query() - url, _ := url.Parse(link) - q := url.Query() + for k, v := range params { + q.Add(k, v) + } - for k, v := range params { - q.Add(k, v) + // Set the new query values on the URL + url.RawQuery = q.Encode() + + url.Fragment = s.genRemark(inbound, email, "") + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ } - - // Set the new query values on the URL - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, "") - return url.String() + + return links } func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { - var address string - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - address = s.address - } else { - address = inbound.Listen - } if inbound.Protocol != model.Shadowsocks { return "" } + + // Get all nodes for this inbound + var nodeAddresses []string + multiMode, _ := s.settingService.GetMultiNodeMode() + if multiMode { + nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) + if err == nil && len(nodes) > 0 { + // Extract addresses from all nodes + for _, node := range nodes { + nodeAddr := s.extractNodeHost(node.Address) + if nodeAddr != "" { + nodeAddresses = append(nodeAddresses, nodeAddr) + } + } + } + } + + // Fallback to default logic if no nodes found + var defaultAddress string + if len(nodeAddresses) == 0 { + if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { + defaultAddress = s.address + } else { + defaultAddress = inbound.Listen + } + nodeAddresses = []string{defaultAddress} + } var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -852,14 +992,18 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st externalProxies, _ := stream["externalProxy"].([]any) + // Generate links for each node address (or external proxy) + links := "" + linkIndex := 0 + + // First, handle external proxies if any if len(externalProxies) > 0 { - links := "" - for index, externalProxy := range externalProxies { + for _, externalProxy := range externalProxies { ep, _ := externalProxy.(map[string]any) newSecurity, _ := ep["forceTls"].(string) dest, _ := ep["dest"].(string) - port := int(ep["port"].(float64)) - link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, port) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, epPort) if newSecurity != "same" { params["security"] = newSecurity @@ -880,27 +1024,38 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) - if index > 0 { + if linkIndex > 0 { links += "\n" } links += url.String() + linkIndex++ } return links } - link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), address, inbound.Port) - url, _ := url.Parse(link) - q := url.Query() + // Generate links for each node address + for _, nodeAddr := range nodeAddresses { + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), nodeAddr, inbound.Port) + url, _ := url.Parse(link) + q := url.Query() - for k, v := range params { - q.Add(k, v) + for k, v := range params { + q.Add(k, v) + } + + // Set the new query values on the URL + url.RawQuery = q.Encode() + + url.Fragment = s.genRemark(inbound, email, "") + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ } - - // Set the new query values on the URL - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, "") - return url.String() + + return links } func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string { @@ -1215,3 +1370,19 @@ func getHostFromXFH(s string) (string, error) { } return s, nil } + +// extractNodeHost extracts the host from a node API address. +// Example: "http://192.168.1.100:8080" -> "192.168.1.100" +func (s *SubService) extractNodeHost(nodeAddress string) string { + // Remove protocol prefix + address := strings.TrimPrefix(nodeAddress, "http://") + address = strings.TrimPrefix(address, "https://") + + // Extract host (remove port if present) + host, _, err := net.SplitHostPort(address) + if err != nil { + // No port, return as is + return address + } + return host +} diff --git a/web/assets/css/custom.min.css b/web/assets/css/custom.min.css index fcbc3a69..6b312c03 100644 --- a/web/assets/css/custom.min.css +++ b/web/assets/css/custom.min.css @@ -1 +1 @@ -:root{--color-primary-100:#008771;--dark-color-background:#0a1222;--dark-color-surface-100:#151f31;--dark-color-surface-200:#222d42;--dark-color-surface-300:#2c3950;--dark-color-surface-400:rgba(65, 85, 119, 0.5);--dark-color-surface-500:#2c3950;--dark-color-surface-600:#313f5a;--dark-color-surface-700:#111929;--dark-color-surface-700-rgb:17, 25, 41;--dark-color-table-hover:rgba(44, 57, 80, 0.2);--dark-color-text-primary:rgba(255, 255, 255, 0.75);--dark-color-stroke:#2c3950;--dark-color-btn-danger:#cd3838;--dark-color-btn-danger-border:transparent;--dark-color-btn-danger-hover:#e94b4b;--dark-color-tag-bg:rgba(255, 255, 255, 0.05);--dark-color-tag-border:rgba(255, 255, 255, 0.15);--dark-color-tag-color:rgba(255, 255, 255, 0.75);--dark-color-tag-green-bg:17, 36, 33;--dark-color-tag-green-border:25, 81, 65;--dark-color-tag-green-color:#3ad3ba;--dark-color-tag-purple-bg:#201425;--dark-color-tag-purple-border:#5a2969;--dark-color-tag-purple-color:#d988cd;--dark-color-tag-red-bg:#291515;--dark-color-tag-red-border:#5c2626;--dark-color-tag-red-color:#e04141;--dark-color-tag-orange-bg:#312313;--dark-color-tag-orange-border:#593914;--dark-color-tag-orange-color:#ffa031;--dark-color-tag-blue-bg:#111a2c;--dark-color-tag-blue-border:#1348ab;--dark-color-tag-blue-color:#529fff;--dark-color-codemirror-line-hover:rgba(0, 135, 113, 0.2);--dark-color-codemirror-line-selection:rgba(0, 135, 113, 0.3);--dark-color-login-background:var(--dark-color-background);--dark-color-login-wave:var(--dark-color-surface-200);--dark-color-tooltip:rgba(61, 76, 104, 0.9);--dark-color-back-top:rgba(61, 76, 104, 0.9);--dark-color-back-top-hover:rgba(61, 76, 104, 1);--dark-color-scrollbar:#313f5a;--dark-color-scrollbar-webkit:#7484a0;--dark-color-scrollbar-webkit-hover:#90a4c7;--dark-color-table-ring:rgb(38 52 77);--dark-color-spin-container:#151f31}html[data-theme-animations='off']{.ant-menu,.ant-layout-sider,.ant-card,.ant-tag,.ant-progress-circle>*,.ant-input,.ant-table-row-expand-icon,.ant-switch,.ant-table-thead>tr>th,.ant-select-selection,.ant-btn,.ant-input-number,.ant-input-group-addon,.ant-checkbox-inner,.ant-progress-bg,.ant-progress-success-bg,.ant-radio-button-wrapper:not(:first-child):before,.ant-radio-button-wrapper,#login,.cm-s-xq.CodeMirror{transition:border 0s,background 0s!important}.ant-menu.ant-menu-inline .ant-menu-item:not(.ant-menu-sub .ant-menu-item),.ant-layout-sider-trigger,.ant-alert-close-icon .anticon-close,.ant-tabs-nav .ant-tabs-tab,.ant-input-number-input,.ant-collapse>.ant-collapse-item>.ant-collapse-header,.Line-Hover,.ant-menu-theme-switch,.ant-menu-submenu-title{transition:color 0s!important}.wave-btn-bg{transition:width 0s!important}}html[data-theme='ultra-dark']{--dark-color-background:#21242a;--dark-color-surface-100:#0c0e12;--dark-color-surface-200:#222327;--dark-color-surface-300:#32353b;--dark-color-surface-400:rgba(255, 255, 255, 0.1);--dark-color-surface-500:#3b404b;--dark-color-surface-600:#505663;--dark-color-surface-700:#101113;--dark-color-surface-700-rgb:16, 17, 19;--dark-color-table-hover:rgba(89, 89, 89, 0.15);--dark-color-text-primary:rgb(255 255 255 / 85%);--dark-color-stroke:#202025;--dark-color-tag-green-bg:17, 36, 33;--dark-color-tag-green-border:29, 95, 77;--dark-color-tag-green-color:#59cbac;--dark-color-tag-purple-bg:#241121;--dark-color-tag-purple-border:#5a2969;--dark-color-tag-purple-color:#d686ca;--dark-color-tag-red-bg:#2a1215;--dark-color-tag-red-border:#58181c;--dark-color-tag-red-color:#e84749;--dark-color-tag-orange-bg:#2b1d11;--dark-color-tag-orange-border:#593815;--dark-color-tag-orange-color:#e89a3c;--dark-color-tag-blue-bg:#111a2c;--dark-color-tag-blue-border:#0f367e;--dark-color-tag-blue-color:#3c89e8;--dark-color-codemirror-line-hover:rgba(82, 84, 94, 0.2);--dark-color-codemirror-line-selection:rgba(82, 84, 94, 0.3);--dark-color-login-background:#0a2227;--dark-color-login-wave:#0f2d32;--dark-color-tooltip:rgba(88, 93, 100, 0.9);--dark-color-back-top:rgba(88, 93, 100, 0.9);--dark-color-back-top-hover:rgba(88, 93, 100, 1);--dark-color-scrollbar:rgb(107,107,107);--dark-color-scrollbar-webkit:#9f9f9f;--dark-color-scrollbar-webkit-hover:#d1d1d1;--dark-color-table-ring:rgb(37 39 42);--dark-color-spin-container:#1d1d1d;.ant-dropdown-menu-dark,.dark .ant-dropdown-menu{background-color:var(--dark-color-surface-500)}.dark .ant-dropdown-menu-submenu-title:hover,.dark .ant-select-dropdown-menu-item-active:not(.ant-select-dropdown-menu-item-disabled),.dark .ant-select-dropdown-menu-item:hover:not(.ant-select-dropdown-menu-item-disabled){background-color:rgb(0 93 78 / .3)}.dark .waves-header{background-color:#0a2227}.dark .ant-calendar-year-panel-year:hover,.dark .ant-calendar-month-panel-month:hover,.dark .ant-calendar-decade-panel-decade:hover{background-color:var(--dark-color-surface-600)}}html,body{height:100vh;width:100vw;margin:0;padding:0;overflow:hidden}body{color:rgb(0 0 0 / .65);font-size:14px;font-variant:tabular-nums;line-height:1.5;background-color:#fff;font-feature-settings:"tnum"}html{--antd-wave-shadow-color:var(--color-primary-100);line-height:1.15;text-size-adjust:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-moz-tap-highlight-color:#fff0;-webkit-tap-highlight-color:#fff0}@supports (scrollbar-width:auto) and (not selector(::-webkit-scrollbar)){:not(.dark){scrollbar-color:#9a9a9a #fff0;scrollbar-width:thin}.dark *{scrollbar-color:var(--dark-color-scrollbar) #fff0;scrollbar-width:thin}}::-webkit-scrollbar{width:10px;height:10px;background-color:#fff0}::-webkit-scrollbar-track{background-color:#fff0;margin-block:.5em}.ant-modal-wrap::-webkit-scrollbar-track{background-color:#fff;margin-block:0}::-webkit-scrollbar-thumb{border-radius:9999px;background-color:#9a9a9a;border:2px solid #fff0;background-clip:content-box}::-webkit-scrollbar-thumb:hover,::-webkit-scrollbar-thumb:active{background-color:#828282}.dark .ant-modal-wrap::-webkit-scrollbar-track{background-color:var(--dark-color-background)}.dark::-webkit-scrollbar-thumb{background-color:var(--dark-color-scrollbar-webkit)}.dark::-webkit-scrollbar-thumb:hover,.dark::-webkit-scrollbar-thumb:active{background-color:var(--dark-color-scrollbar-webkit-hover)}::-moz-selection{color:var(--color-primary-100);background-color:#cfe8e4}::selection{color:var(--color-primary-100);background-color:#cfe8e4}#app{height:100%;position:fixed;top:0;left:0;right:0;bottom:0;margin:0;padding:0;overflow:auto}.ant-layout,.ant-layout *{box-sizing:border-box}.ant-spin-container:after{border-radius:1.5rem}.dark .ant-spin-container:after{background:var(--dark-color-spin-container)}style attribute{text-align:center}.ant-table-thead>tr>th{padding:12px 8px}.ant-table-tbody>tr>td{padding:10px 8px}.ant-table-thead>tr>th{color:rgb(0 0 0 / .85);font-weight:500;text-align:left;border-bottom:1px solid #e8e8e8;transition:background 0.3s ease}.ant-table table{border-radius:1rem}.ant-table-bordered .ant-table-tbody:not(.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody)>tr:last-child>td:first-child{border-bottom-left-radius:1rem}.ant-table-bordered .ant-table-tbody:not(.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody)>tr:last-child>td:last-child{border-bottom-right-radius:1rem}.ant-table{box-sizing:border-box;margin:0;padding:0;color:rgb(0 0 0 / .65);font-size:14px;font-variant:tabular-nums;line-height:1.5;list-style:none;font-feature-settings:"tnum";position:relative;clear:both}.ant-table .ant-table-body:not(.ant-table-expanded-row .ant-table-body){overflow-x:auto!important}.ant-card-hoverable{cursor:auto;cursor:pointer}.ant-card{box-sizing:border-box;margin:0;padding:0;color:rgb(0 0 0 / .65);font-size:14px;font-variant:tabular-nums;line-height:1.5;list-style:none;position:relative;background-color:#fff;border-radius:2px;transition:all 0.3s}.ant-space{width:100%}.ant-layout-sider-zero-width-trigger{display:none}@media (max-width:768px){.ant-layout-sider{display:none}.ant-card,.ant-alert-error{margin:.5rem}.ant-tabs{margin:.5rem;padding:.5rem}.ant-modal-body{padding:20px}.ant-form-item-label{line-height:1.5;padding:8px 0 0}:not(.dark)::-webkit-scrollbar{width:8px;height:8px;background-color:#fff0}.dark::-webkit-scrollbar{width:8px;height:8px;background-color:#fff0}}.ant-layout-content{min-height:auto}.ant-card,.ant-tabs{border-radius:1.5rem}.ant-card-hoverable{cursor:auto}.ant-card+.ant-card{margin-top:20px}.drawer-handle{position:absolute;top:72px;width:41px;height:40px;cursor:pointer;z-index:0;text-align:center;line-height:40px;font-size:16px;display:flex;justify-content:center;align-items:center;background-color:#fff;right:-40px;box-shadow:2px 0 8px rgb(0 0 0 / .15);border-radius:0 4px 4px 0}.ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected{background-color:#006655!important;background-image:linear-gradient(270deg,#fff0 30%,#009980,#fff0 100%);background-repeat:no-repeat;animation:ma-bg-move linear 6.6s infinite;color:#fff;border-radius:.5rem}.ant-layout-sider-collapsed .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected{border-radius:0}.ant-menu-item-active,.ant-menu-item:hover,.ant-menu-submenu-title:hover,.ant-menu-item:active,.ant-menu-submenu-title:active{color:var(--color-primary-100);background-color:#e8f4f2}.ant-menu-inline .ant-menu-item,.ant-menu-inline .ant-menu-submenu-title{border-radius:.5rem}.ant-menu-inline .ant-menu-item:after,.ant-menu{border-right-width:0}.ant-layout-sider-children,.ant-pagination ul{padding:.5rem}.ant-layout-sider-collapsed .ant-layout-sider-children{padding:.5rem 0}.ant-dropdown-menu,.ant-select-dropdown-menu{padding:.5rem}.ant-dropdown-menu-item,.ant-dropdown-menu-item:hover,.ant-select-dropdown-menu-item,.ant-select-dropdown-menu-item:hover,.ant-select-selection--multiple .ant-select-selection__choice{border-radius:.5rem}.ant-select-dropdown--multiple .ant-select-dropdown-menu .ant-select-dropdown-menu-item,.ant-select-dropdown--single .ant-select-dropdown-menu .ant-select-dropdown-menu-item-selected{margin-block:2px}@media (min-width:769px){.drawer-handle{display:none}.ant-tabs{padding:2rem}}.fade-in-enter,.fade-in-leave-active,.fade-in-linear-enter,.fade-in-linear-leave,.fade-in-linear-leave-active,.fade-in-linear-enter,.fade-in-linear-leave,.fade-in-linear-leave-active{opacity:0}.fade-in-linear-enter-active,.fade-in-linear-leave-active{-webkit-transition:opacity 0.2s linear;transition:opacity 0.2s linear}.fade-in-linear-enter-active,.fade-in-linear-leave-active{-webkit-transition:opacity 0.2s linear;transition:opacity 0.2s linear}.fade-in-enter-active,.fade-in-leave-active{-webkit-transition:all 0.3s cubic-bezier(.55,0,.1,1);transition:all 0.3s cubic-bezier(.55,0,.1,1)}.zoom-in-center-enter-active,.zoom-in-center-leave-active{-webkit-transition:all 0.3s cubic-bezier(.55,0,.1,1);transition:all 0.3s cubic-bezier(.55,0,.1,1)}.zoom-in-center-enter,.zoom-in-center-leave-active{opacity:0;-webkit-transform:scaleX(0);transform:scaleX(0)}.zoom-in-top-enter-active,.zoom-in-top-leave-active{opacity:1;-webkit-transform:scaleY(1);transform:scaleY(1);-webkit-transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:center top;transform-origin:center top}.zoom-in-top-enter,.zoom-in-top-leave-active{opacity:0;-webkit-transform:scaleY(0);transform:scaleY(0)}.zoom-in-bottom-enter-active,.zoom-in-bottom-leave-active{opacity:1;-webkit-transform:scaleY(1);transform:scaleY(1);-webkit-transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:center bottom;transform-origin:center bottom}.zoom-in-bottom-enter,.zoom-in-bottom-leave-active{opacity:0;-webkit-transform:scaleY(0);transform:scaleY(0)}.zoom-in-left-enter-active,.zoom-in-left-leave-active{opacity:1;-webkit-transform:scale(1,1);transform:scale(1,1);-webkit-transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:top left;transform-origin:top left}.zoom-in-left-enter,.zoom-in-left-leave-active{opacity:0;-webkit-transform:scale(.45,.45);transform:scale(.45,.45)}.list-enter-active,.list-leave-active{-webkit-transition:all 0.3s;transition:all 0.3s}.list-enter,.list-leave-active{opacity:0;-webkit-transform:translateY(-30px);transform:translateY(-30px)}.ant-tooltip-inner{min-height:0;padding-inline:1rem}.ant-list-item-meta-title{font-size:14px}.ant-progress-inner{background-color:#ebeef5}.deactive-client .ant-collapse-header{color:#ffffff!important;background-color:#ff7f7f}.ant-table-expand-icon-th,.ant-table-row-expand-icon-cell{width:30px;min-width:30px}.ant-tabs{background-color:#fff}.ant-form-item{margin-bottom:0}.ant-setting-textarea{margin-top:1.5rem}.client-table-header{background-color:#f0f2f5}.client-table-odd-row{background-color:#fafafa}.ant-table-pagination.ant-pagination{float:left}.ant-tag{margin-right:0;margin-inline:2px;display:inline-flex;align-items:center;justify-content:space-evenly}.ant-tag:not(.qr-tag){column-gap:4px}#inbound-info-modal .ant-tag{margin-block:2px}.tr-info-table{display:inline-table;margin-block:10px;width:100%}#inbound-info-modal .tr-info-table .ant-tag{margin-block:0;margin-inline:0}.tr-info-row{display:flex;flex-direction:column;row-gap:2px;margin-block:10px}.tr-info-row a{margin-left:6px}.tr-info-row code{padding-inline:8px;max-height:80px;overflow-y:auto}.tr-info-tag{max-width:100%;text-wrap:balance;overflow:hidden;overflow-wrap:anywhere}.tr-info-title{display:inline-flex;align-items:center;justify-content:flex-start;column-gap:4px}.ant-tag-blue{background-color:#edf4fa;border-color:#a9c5e7;color:#0e49b5}.ant-tag-green{background-color:#eafff9;border-color:#76ccb4;color:#199270}.ant-tag-purple{background-color:#f2eaf1;border-color:#d5bed2;color:#7a316f}.ant-tag-orange,.ant-alert-warning{background-color:#ffeee1;border-color:#fec093;color:#f37b24}.ant-tag-red,.ant-alert-error{background-color:#ffe9e9;border-color:#ff9e9e;color:#cf3c3c}.ant-input::placeholder{opacity:.5}.ant-input:hover,.ant-input:focus{background-color:#e8f4f2}.ant-input-affix-wrapper:hover .ant-input:not(.ant-input-disabled){background-color:#e8f4f2}.delete-icon:hover{color:#e04141}.normal-icon:hover{color:var(--color-primary-100)}.dark ::-moz-selection{color:#fff;background-color:var(--color-primary-100)}.dark ::selection{color:#fff;background-color:var(--color-primary-100)}.dark .normal-icon:hover{color:#fff}.dark .ant-layout-sider,.dark .ant-drawer-content,.ant-menu-dark,.ant-menu-dark .ant-menu-sub,.dark .ant-card,.dark .ant-table,.dark .ant-collapse-content,.dark .ant-tabs{background-color:var(--dark-color-surface-100);color:var(--dark-color-text-primary)}.dark .ant-card-hoverable:hover,.dark .ant-space-item>.ant-tabs:hover{box-shadow:0 2px 8px #fff0}.dark>.ant-layout,.dark .drawer-handle,.dark .ant-table-thead>tr>th,.dark .ant-table-expanded-row,.dark .ant-table-expanded-row:hover,.dark .ant-table-expanded-row .ant-table-tbody,.dark .ant-calendar{background-color:var(--dark-color-background);color:var(--dark-color-text-primary)}.dark .ant-table-expanded-row .ant-table-thead>tr:first-child>th{border-radius:0}.dark .ant-calendar,.dark .ant-card-bordered{border-color:var(--dark-color-background)}.dark .ant-table-bordered,.dark .ant-table-bordered.ant-table-empty .ant-table-placeholder,.dark .ant-table-bordered .ant-table-body>table,.dark .ant-table-bordered .ant-table-fixed-left table,.dark .ant-table-bordered .ant-table-fixed-right table,.dark .ant-table-bordered .ant-table-header>table,.dark .ant-table-bordered .ant-table-thead>tr:not(:last-child)>th,.dark .ant-table-bordered .ant-table-tbody>tr>td,.dark .ant-table-bordered .ant-table-thead>tr>th{border-color:var(--dark-color-surface-400)}.dark .ant-table-tbody>tr>td,.dark .ant-table-thead>tr>th,.dark .ant-card-head,.dark .ant-modal-header,.dark .ant-collapse>.ant-collapse-item,.dark .ant-tabs-bar,.dark .ant-list-split .ant-list-item,.dark .ant-popover-title,.dark .ant-calendar-header,.dark .ant-calendar-input-wrap{border-bottom-color:var(--dark-color-surface-400)}.dark .ant-modal-footer,.dark .ant-collapse-content,.dark .ant-calendar-footer,.dark .ant-divider-horizontal.ant-divider-with-text-left:before,.dark .ant-divider-horizontal.ant-divider-with-text-left:after,.dark .ant-divider-horizontal.ant-divider-with-text-center:before,.dark .ant-divider-horizontal.ant-divider-with-text-center:after{border-top-color:var(--dark-color-surface-300)}.ant-divider-horizontal.ant-divider-with-text-left:before{width:10%}.dark .ant-progress-text,.dark .ant-card-head,.dark .ant-form,.dark .ant-collapse>.ant-collapse-item>.ant-collapse-header,.dark .ant-modal-close-x,.dark .ant-form .anticon,.dark .ant-tabs-tab-arrow-show:not(.ant-tabs-tab-btn-disabled),.dark .anticon-close,.dark .ant-list-item-meta-title,.dark .ant-select-selection i,.dark .ant-modal-confirm-title,.dark .ant-modal-confirm-content,.dark .ant-popover-message,.dark .ant-modal,.dark .ant-divider-inner-text,.dark .ant-popover-title,.dark .ant-popover-inner-content,.dark h2,.dark .ant-modal-title,.dark .ant-form-item-label>label,.dark .ant-checkbox-wrapper,.dark .ant-form-item,.dark .ant-calendar-footer .ant-calendar-today-btn,.dark .ant-calendar-footer .ant-calendar-time-picker-btn,.dark .ant-calendar-day-select,.dark .ant-calendar-month-select,.dark .ant-calendar-year-select,.dark .ant-calendar-date,.dark .ant-calendar-year-panel-year,.dark .ant-calendar-month-panel-month,.dark .ant-calendar-decade-panel-decade{color:var(--dark-color-text-primary)}.dark .ant-pagination-options-size-changer .ant-select-arrow .anticon.anticon-down.ant-select-arrow-icon{color:rgb(255 255 255 / 35%)}.dark .ant-pagination-item a,.dark .ant-pagination-next a,.dark .ant-pagination-prev a{color:var(--dark-color-text-primary)}.dark .ant-pagination-item:focus a,.dark .ant-pagination-item:hover a,.dark .ant-pagination-item-active a,.dark .ant-pagination-next:hover .ant-pagination-item-link{color:var(--color-primary-100)}.dark .ant-pagination-item-active{background-color:#fff0}.dark .ant-list-item-meta-description{color:rgb(255 255 255 / .45)}.dark .ant-pagination-disabled i,.dark .ant-tabs-tab-btn-disabled{color:rgb(255 255 255 / .25)}.dark .ant-input,.dark .ant-input-group-addon,.dark .ant-collapse,.dark .ant-select-selection,.dark .ant-input-number,.dark .ant-input-number-handler-wrap,.dark .ant-table-placeholder,.dark .ant-empty-normal,.dark .ant-select-dropdown,.dark .ant-select-dropdown li,.dark .ant-select-dropdown-menu-item,.dark .client-table-header,.dark .ant-select-selection--multiple .ant-select-selection__choice{background-color:var(--dark-color-surface-200);border-color:var(--dark-color-surface-300);color:var(--dark-color-text-primary)}.dark .ant-select-dropdown--multiple .ant-select-dropdown-menu .ant-select-dropdown-menu-item.ant-select-dropdown-menu-item-selected :not(.ant-dropdown-menu-submenu-title:hover){background-color:var(--dark-color-surface-300)}.dark .ant-select-dropdown-menu-item.ant-select-dropdown-menu-item-selected{background-color:var(--dark-color-surface-300)}.dark .ant-calendar-time-picker-inner{background-color:var(--dark-color-background)}.dark .ant-select-selection:hover,.dark .ant-calendar-picker-clear,.dark .ant-input-number:hover,.dark .ant-input-number:focus,.dark .ant-input:hover,.dark .ant-input:focus{background-color:rgb(0 135 113 / .3);border-color:var(--color-primary-100)}.dark .ant-input-affix-wrapper:hover .ant-input:not(.ant-input-disabled){border-color:var(--color-primary-100);background-color:rgb(0 135 113 / .3)}.dark .ant-btn:not(.ant-btn-primary):not(.ant-btn-danger){color:var(--dark-color-text-primary);background-color:rgb(10 117 87 / 30%);border:1px solid var(--color-primary-100)}.dark .ant-radio-button-wrapper,.dark .ant-radio-button-wrapper:before{color:var(--dark-color-text-primary);background-color:rgb(0 135 113 / .3);border-color:var(--color-primary-100)}.ant-btn:focus:not(.ant-btn-primary):not(.ant-btn-danger),.ant-btn:hover:not(.ant-btn-primary):not(.ant-btn-danger){background-color:#e8f4f2}.dark .ant-btn:focus:not(.ant-btn-primary):not(.ant-btn-danger),.dark .ant-btn:hover:not(.ant-btn-primary):not(.ant-btn-danger){color:#fff;background-color:rgb(10 117 87 / 50%);border-color:var(--color-primary-100)}.dark .ant-btn-primary[disabled],.dark .ant-btn-danger[disabled],.dark .ant-calendar-ok-btn-disabled{color:rgb(255 255 255 / 35%);background-color:var(--dark-color-surface-200);border-color:var(--dark-color-surface-300)}.dark .ant-table-tbody>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.dark .client-table-odd-row{background-color:var(--dark-color-table-hover)}.dark .ant-table-row-expand-icon{color:#fff;background-color:#fff0;border-color:rgb(255 255 255 / 20%)}.dark .ant-table-row-expand-icon:hover{color:var(--color-primary-100);background-color:#fff0;border-color:var(--color-primary-100)}.dark .ant-switch:not(.ant-switch-checked),.dark .ant-progress-line .ant-progress-inner{background-color:var(--dark-color-surface-500)}.dark .ant-progress-circle-trail{stroke:var(--dark-color-stroke)!important}.dark .ant-popover-inner{background-color:var(--dark-color-surface-500)}.dark>.ant-popover-content>.ant-popover-arrow{border-color:var(--dark-color-surface-500)}@media (max-width:768px){.dark .ant-popover-inner{background-color:var(--dark-color-surface-200)}.dark>.ant-popover-content>.ant-popover-arrow{border-color:var(--dark-color-surface-200)}}.ant-dropdown-menu-dark .ant-dropdown-menu-item:hover,.dark .ant-select-dropdown-menu-item-selected,.dark .ant-calendar-time-picker-select-option-selected{background-color:var(--dark-color-surface-600)}.ant-menu-dark .ant-menu-item:hover,.ant-menu-dark .ant-menu-submenu-title:hover{background-color:var(--dark-color-surface-300)}.dark .ant-menu-item:active,.dark .ant-menu-submenu-title:active{color:#fff;background-color:var(--dark-color-surface-300)}.dark .ant-alert-message{color:rgb(255 255 255 / .85)}.dark .ant-tag{color:var(--dark-color-tag-color);background-color:var(--dark-color-tag-bg);border-color:var(--dark-color-tag-border)}.dark .ant-tag-blue{background-color:var(--dark-color-tag-blue-bg);border-color:var(--dark-color-tag-blue-border);color:var(--dark-color-tag-blue-color)}.dark .ant-tag-red,.dark .ant-alert-error{background-color:var(--dark-color-tag-red-bg);border-color:var(--dark-color-tag-red-border);color:var(--dark-color-tag-red-color)}.dark .ant-tag-orange,.dark .ant-alert-warning{background-color:var(--dark-color-tag-orange-bg);border-color:var(--dark-color-tag-orange-border);color:var(--dark-color-tag-orange-color)}.dark .ant-tag-green{background-color:rgb(var(--dark-color-tag-green-bg));border-color:rgb(var(--dark-color-tag-green-border));color:var(--dark-color-tag-green-color)}.dark .ant-tag-purple{background-color:var(--dark-color-tag-purple-bg);border-color:var(--dark-color-tag-purple-border);color:var(--dark-color-tag-purple-color)}.dark .ant-modal-content,.dark .ant-modal-header{background-color:var(--dark-color-surface-700)}.dark .ant-calendar-next-month-btn-day .ant-calendar-date,.dark .ant-calendar-last-month-cell .ant-calendar-date{color:var(--dark-color-surface-300)}.dark .ant-calendar-selected-day .ant-calendar-date{background-color:var(--color-primary-100)!important;color:#fff}.dark .ant-calendar-date:hover,.dark .ant-calendar-time-picker-select li:hover{background-color:var(--dark-color-surface-600);color:#fff}.dark .ant-calendar-header a:hover,.dark .ant-calendar-header a:hover::before,.dark .ant-calendar-header a:hover::after{border-color:#fff}.dark .ant-calendar-time-picker-select{border-right-color:var(--dark-color-surface-300)}.has-warning .ant-select-selection,.has-warning .ant-select-selection:hover,.has-warning .ant-input,.has-warning .ant-input:hover{background-color:#ffeee1;border-color:#fec093}.has-warning .ant-input::placeholder{color:#f37b24}.has-warning .ant-input:not([disabled]):hover{border-color:#fec093}.dark .has-warning .ant-select-selection,.dark .has-warning .ant-select-selection:hover,.dark .has-warning .ant-input,.dark .has-warning .ant-input:hover{border-color:#784e1d;background:#312313}.dark .has-warning .ant-input::placeholder{color:rgb(255 160 49 / 70%)}.dark .has-warning .anticon{color:#ffa031}.dark .has-success .anticon{color:var(--color-primary-100);animation-name:diffZoomIn1!important}.dark .anticon-close-circle{color:#e04141}.dark .ant-spin-nested-loading>div>.ant-spin .ant-spin-text{text-shadow:0 1px 2px #0007}.dark .ant-spin{color:#fff}.dark .ant-spin-dot-item{background-color:#fff}.ant-checkbox-wrapper,.ant-input-group-addon,.ant-tabs-tab,.ant-input::placeholder,.ant-collapse-header,.ant-menu,.ant-radio-button-wrapper{-webkit-user-select:none;user-select:none}.ant-calendar-date,.ant-calendar-year-panel-year,.ant-calendar-decade-panel-decade,.ant-calendar-month-panel-month{border-radius:4px}.ant-checkbox-inner,.ant-checkbox-checked:after,.ant-table-row-expand-icon{border-radius:6px}.ant-calendar-date:hover{background-color:#e8f4f2}.ant-calendar-date:active{background-color:#e8f4f2;color:rgb(0 0 0 / .65)}.ant-calendar-today .ant-calendar-date{color:var(--color-primary-100);font-weight:400;border-color:var(--color-primary-100)}.dark .ant-calendar-today .ant-calendar-date{color:#fff;border-color:var(--color-primary-100)}.ant-calendar-selected-day .ant-calendar-date{background:var(--color-primary-100);color:#fff}li.ant-select-dropdown-menu-item:empty:after{content:"None";font-weight:400;color:rgb(0 0 0 / .25)}.dark li.ant-select-dropdown-menu-item:empty:after{content:"None";font-weight:400;color:rgb(255 255 255 / .3)}.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item:hover .ant-select-selected-icon{color:rgb(0 0 0 / .87)}.dark.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item:hover .ant-select-selected-icon{color:#fff}.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item-selected .ant-select-selected-icon,.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item-selected:hover .ant-select-selected-icon{color:var(--color-primary-100)}.ant-select-selection:hover,.ant-input-number-focused,.ant-input-number:hover{background-color:#e8f4f2}.dark .ant-input-number-handler:active{background-color:var(--color-primary-100)}.dark .ant-input-number-handler:hover .ant-input-number-handler-down-inner,.dark .ant-input-number-handler:hover .ant-input-number-handler-up-inner{color:#fff}.dark .ant-input-number-handler-down{border-top:1px solid rgb(217 217 217 / .3)}.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-century-select,.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-decade-select,.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-month-select,.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-year-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-century-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-decade-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-month-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-year-select{color:rgb(255 255 255 / .85)}.dark .ant-calendar-year-panel-header{border-bottom:1px solid var(--dark-color-surface-200)}.dark .ant-calendar-year-panel-last-decade-cell .ant-calendar-year-panel-year,.dark .ant-calendar-year-panel-next-decade-cell .ant-calendar-year-panel-year{color:rgb(255 255 255 / .35)}.dark .ant-divider:not(.ant-divider-with-text-center,.ant-divider-with-text-left,.ant-divider-with-text-right),.ant-dropdown-menu-dark,.dark .ant-calendar-year-panel-year:hover,.dark .ant-calendar-month-panel-month:hover,.dark .ant-calendar-decade-panel-decade:hover{background-color:var(--dark-color-surface-200)}.dark .ant-calendar-header a:hover{color:#fff}.dark .ant-calendar-month-panel-header{background-color:var(--dark-color-background);border-bottom:1px solid var(--dark-color-surface-200)}.dark .ant-calendar-year-panel,.dark .ant-calendar table{background-color:var(--dark-color-background)}.dark .ant-calendar-year-panel-selected-cell .ant-calendar-year-panel-year,.dark .ant-calendar-year-panel-selected-cell .ant-calendar-year-panel-year:hover,.dark .ant-calendar-month-panel-selected-cell .ant-calendar-month-panel-month,.dark .ant-calendar-month-panel-selected-cell .ant-calendar-month-panel-month:hover,.dark .ant-calendar-decade-panel-selected-cell .ant-calendar-decade-panel-decade,.dark .ant-calendar-decade-panel-selected-cell .ant-calendar-decade-panel-decade:hover{color:#fff;background-color:var(--color-primary-100)!important}.dark .ant-calendar-last-month-cell .ant-calendar-date,.dark .ant-calendar-last-month-cell .ant-calendar-date:hover,.dark .ant-calendar-next-month-btn-day .ant-calendar-date,.dark .ant-calendar-next-month-btn-day .ant-calendar-date:hover{color:rgb(255 255 255 / 25%);background:#fff0;border-color:#fff0}.dark .ant-calendar-today .ant-calendar-date:hover{color:#fff;border-color:var(--color-primary-100);background-color:var(--color-primary-100)}.dark .ant-calendar-decade-panel-last-century-cell .ant-calendar-decade-panel-decade,.dark .ant-calendar-decade-panel-next-century-cell .ant-calendar-decade-panel-decade{color:rgb(255 255 255 / 25%)}.dark .ant-calendar-decade-panel-header{border-bottom:1px solid var(--dark-color-surface-200);background-color:var(--dark-color-background)}.dark .ant-checkbox-inner{background-color:rgb(0 135 113 / .3);border-color:rgb(0 135 113 / .3)}.dark .ant-checkbox-checked .ant-checkbox-inner{background-color:var(--color-primary-100);border-color:var(--color-primary-100)}.dark .ant-calendar-input{background-color:var(--dark-color-background);color:var(--dark-color-text-primary)}.dark .ant-calendar-input::placeholder{color:rgb(255 255 255 / .25)}.ant-input-group.ant-input-group-compact-addon:not(:first-child):not(:last-child),.ant-input-group.ant-input-group-compact-wrap:not(:first-child):not(:last-child),.ant-input-group.ant-input-group-compact>.ant-input:not(:first-child):not(:last-child),.ant-input-number-handler,.ant-input-number-handler-wrap{border-radius:0}.ant-input-number{overflow:clip}.ant-modal-body,.ant-collapse-content>.ant-collapse-content-box{overflow-x:auto}.ant-modal-body{overflow-y:hidden}.ant-calendar-year-panel-year:hover,.ant-calendar-decade-panel-decade:hover,.ant-calendar-month-panel-month:hover,.ant-dropdown-menu-item:hover,.ant-dropdown-menu-submenu-title:hover,.ant-select-dropdown-menu-item-active:not(.ant-select-dropdown-menu-item-disabled),.ant-select-dropdown-menu-item:hover:not(.ant-select-dropdown-menu-item-disabled),.ant-table-tbody>tr.ant-table-row-hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.ant-table-tbody>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.ant-table-thead>tr.ant-table-row-hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.ant-table-thead>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td{background-color:#e8f4f2}.dark .ant-dropdown-menu-submenu-title:hover,.dark .ant-select-dropdown-menu-item-active:not(.ant-select-dropdown-menu-item-disabled),.dark .ant-select-dropdown-menu-item:hover:not(.ant-select-dropdown-menu-item-disabled){background-color:rgb(0 93 78 / .3)}.ant-select-dropdown,.ant-popover-inner{overflow-x:hidden}.ant-popover-inner-content{max-height:450px;overflow-y:auto}@media (max-height:900px){.ant-popover-inner-content{max-height:400px}}@media (max-height:768px){.ant-popover-inner-content{max-height:300px}}@media (max-width:768px){.ant-popover-inner-content{max-height:300px}}.qr-modal{display:flex;align-items:flex-end;gap:10px;flex-direction:column;flex-wrap:wrap;row-gap:24px}.qr-box{width:220px}.qr-cv{width:100%;height:100%}.dark .qr-cv{background-color:#fff;padding:1px;border-radius:0.25rem}.qr-bg{background-color:#fff;display:flex;justify-content:center;align-content:center;padding:.8rem;border-radius:1rem;border:solid 1px #e8e8e8;height:220px;width:220px;transition:all 0.1s}.qr-bg:hover{border-color:#76ccb4;background-color:#eafff9}.qr-bg:hover:active{border-color:#76ccb4;background-color:rgb(197 241 228 / 70%)}.dark .qr-bg{background-color:var(--dark-color-surface-700);border-color:var(--dark-color-surface-300)}.dark .qr-bg:hover{background-color:rgb(var(--dark-color-tag-green-bg));border-color:rgb(var(--dark-color-tag-green-border))}.dark .qr-bg:hover:active{background-color:#17322e}@property --tr-rotate{syntax:'';initial-value:45deg;inherits:false}.qr-bg-sub{background-image:linear-gradient(var(--tr-rotate),#76ccb4,transparent,#d5bed2);display:flex;justify-content:center;align-content:center;padding:1px;border-radius:1rem;height:220px;width:220px}.dark .qr-bg-sub{background-image:linear-gradient(var(--tr-rotate),#195141,transparent,#5a2969)}.qr-bg-sub:hover{animation:tr-rotate-gradient 3.5s linear infinite}@keyframes tr-rotate-gradient{from{--tr-rotate:45deg}to{--tr-rotate:405deg}}.qr-bg-sub-inner{background-color:#fff;padding:.8rem;border-radius:1rem;transition:all 0.1s}.qr-bg-sub-inner:hover{background-color:rgb(255 255 255 / 60%);backdrop-filter:blur(25px)}.qr-bg-sub-inner:hover:active{background-color:rgb(255 255 255 / 30%)}.dark .qr-bg-sub-inner{background-color:rgb(var(--dark-color-surface-700-rgb))}.dark .qr-bg-sub-inner:hover{background-color:rgba(var(--dark-color-surface-700-rgb),.5);backdrop-filter:blur(25px)}.dark .qr-bg-sub-inner:hover:active{background-color:rgba(var(--dark-color-surface-700-rgb),.2)}.qr-tag{text-align:center;margin-bottom:10px;width:100%;overflow:hidden;margin-inline:0}@media (min-width:769px){.qr-modal{flex-direction:row;max-width:680px}}.tr-marquee{justify-content:flex-start}.tr-marquee span{padding-right:25%;white-space:nowrap;transform-origin:center}@keyframes move-ltr{0%{transform:translateX(0)}100%{transform:translateX(-100%)}}.ant-input-group-addon:not(:first-child):not(:last-child){border-radius:0rem 1rem 1rem 0rem}b,strong{font-weight:500}.ant-collapse>.ant-collapse-item>.ant-collapse-header{padding:10px 16px 10px 40px}.dark .ant-message-notice-content{background-color:var(--dark-color-surface-200);border:1px solid var(--dark-color-surface-300);color:var(--dark-color-text-primary)}.ant-btn-danger{background-color:var(--dark-color-btn-danger);border-color:var(--dark-color-btn-danger-border)}.ant-btn-danger:focus,.ant-btn-danger:hover{background-color:var(--dark-color-btn-danger-hover);border-color:var(--dark-color-btn-danger-hover)}.dark .ant-alert-close-icon .anticon-close:hover{color:#fff}.ant-empty-small{margin:4px 0;background-color:transparent!important}.ant-empty-small .ant-empty-image{height:20px}.ant-menu-theme-switch,.ant-menu-theme-switch:hover{background-color:transparent!important;cursor:default!important}.dark .ant-tooltip-inner,.dark .ant-tooltip-arrow:before{background-color:var(--dark-color-tooltip)}.ant-select-sm .ant-select-selection__rendered{margin-left:10px}.ant-collapse{-moz-animation:collfade 0.3s ease;-webkit-animation:0.3s collfade 0.3s ease;animation:collfade 0.3s ease}@-webkit-keyframes collfade{0%{transform:scaleY(.8);transform-origin:0% 0%;opacity:0}100%{transform:scaleY(1);transform-origin:0% 0%;opacity:1}}@keyframes collfade{0%{transform:scaleY(.8);transform-origin:0% 0%;opacity:0}100%{transform:scaleY(1);transform-origin:0% 0%;opacity:1}}.ant-table-tbody>tr>td{border-color:#f0f0f0}.ant-table-row-expand-icon{vertical-align:middle;margin-inline-end:8px;position:relative;transform:scale(.9411764705882353)}.ant-table-row-collapsed::before{transform:rotate(-180deg);top:7px;inset-inline-end:3px;inset-inline-start:3px;height:1px;position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-table-row-collapsed::after{transform:rotate(0deg);top:3px;bottom:3px;inset-inline-start:7px;width:1px;position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-table-row-expanded::before{top:7px;inset-inline-end:3px;inset-inline-start:3px;height:1px;position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-table-row-expanded::after{top:3px;bottom:3px;inset-inline-start:7px;width:1px;transform:rotate(90deg);position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-menu-theme-switch.ant-menu-item .ant-switch:not(.ant-switch-disabled):active:after,.ant-switch:not(.ant-switch-disabled):active:before{width:16px}.dark .ant-select-disabled .ant-select-selection{background:var(--dark-color-surface-100);border-color:var(--dark-color-surface-200);color:rgb(255 255 255 / .25)}.dark .ant-select-disabled .anticon{color:rgb(255 255 255 / .25)}.dark .ant-input-number-handler-down-disabled,.dark .ant-input-number-handler-up-disabled{background-color:rgb(0 0 0 / .1)}.dark .ant-input-number-handler-down-disabled .anticon,.dark .ant-input-number-handler-up-disabled .anticon,.dark .ant-input-number-handler-down:hover.ant-input-number-handler-down-disabled .anticon,.dark .ant-input-number-handler-up:hover.ant-input-number-handler-up-disabled .anticon{color:rgb(255 255 255 / .25)}.dark .ant-input-number-handler-down:active.ant-input-number-handler-down-disabled,.dark .ant-input-number-handler-up:active.ant-input-number-handler-up-disabled{background-color:rgb(0 0 0 / .2)}.ant-menu-dark .ant-menu-inline.ant-menu-sub{background:var(--dark-color-surface-100);box-shadow:none}.dark .ant-layout-sider-trigger{background:var(--dark-color-surface-100);color:rgb(255 255 255 / 65%)}.ant-layout-sider{overflow:auto}.dark .ant-back-top-content{background-color:var(--dark-color-back-top)}.dark .ant-back-top-content:hover{background-color:var(--dark-color-back-top-hover)}.ant-calendar-time .ant-calendar-footer .ant-calendar-time-picker-btn{text-transform:capitalize}.ant-calendar{border-color:#fff0;border-width:0}.ant-calendar-time-picker-select li:focus,li.ant-calendar-time-picker-select-option-selected{color:rgb(0 0 0 / .65);font-weight:400;background-color:#e8f4f2}.dark li.ant-calendar-time-picker-select-option-selected{color:var(--dark-color-text-primary);font-weight:400}.dark .ant-calendar-time-picker-select li:focus{color:#fff;font-weight:400;background-color:var(--color-primary-100)}.ant-calendar-time-picker-select li:hover{background:#f5f5f5}.ant-calendar-date{transition:background .3s ease,color .3s ease}li.ant-calendar-time-picker-select-option-selected{margin-block:2px}.ant-calendar-time-picker-select{padding:4px}.ant-calendar-time-picker-select li{height:28px;line-height:28px;border-radius:4px}@media (min-width:769px){.index-page .ant-layout-content{margin:24px 16px}}.index-page .ant-card-dark h2{color:var(--dark-color-text-primary)}.index-page~div .ant-backup-list-item{gap:10px}.index-page~div .ant-version-list-item{--padding:12px;padding:var(--padding)!important;gap:var(--padding)}.index-page.dark~div .ant-version-list-item svg{color:var(--dark-color-text-primary)}.index-page.dark~div .ant-backup-list-item svg,.index-page.dark .ant-badge-status-text,.index-page.dark .ant-card-extra{color:var(--dark-color-text-primary)}.index-page.dark .ant-card-actions>li{color:rgb(255 255 255 / .55)}.index-page.dark~div .ant-radio-inner{background-color:var(--dark-color-surface-100);border-color:var(--dark-color-surface-600)}.index-page.dark~div .ant-radio-checked .ant-radio-inner{border-color:var(--color-primary-100)}.index-page.dark~div .ant-backup-list,.index-page.dark~div .ant-version-list,.index-page.dark .ant-card-actions,.index-page.dark .ant-card-actions>li:not(:last-child){border-color:var(--dark-color-stroke)}.index-page .ant-card-actions{background:#fff0}.index-page .ip-hidden{-webkit-user-select:none;-moz-user-select:none;user-select:none;filter:blur(10px)}.index-page .xray-running-animation .ant-badge-status-dot,.index-page .xray-processing-animation .ant-badge-status-dot{animation:runningAnimation 1.2s linear infinite}.index-page .xray-running-animation .ant-badge-status-processing:after{border-color:var(--color-primary-100)}.index-page .xray-stop-animation .ant-badge-status-processing:after{border-color:#fa8c16}.index-page .xray-error-animation .ant-badge-status-processing:after{border-color:#f5222d}@keyframes runningAnimation{0%,50%,100%{transform:scale(1);opacity:1}10%{transform:scale(1.5);opacity:.2}}.index-page .card-placeholder{text-align:center;padding:30px 0;margin-top:10px;background:#fff0;border:none}.index-page~div .log-container{height:auto;max-height:500px;overflow:auto;margin-top:.5rem}#app.login-app *{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#app.login-app h1{text-align:center;height:110px}#app.login-app .ant-form-item-children .ant-btn,#app.login-app .ant-input{height:50px;border-radius:30px}#app.login-app .ant-input-group-addon{border-radius:0 30px 30px 0;width:50px;font-size:18px}#app.login-app .ant-input-affix-wrapper .ant-input-prefix{left:23px}#app.login-app .ant-input-affix-wrapper .ant-input:not(:first-child){padding-left:50px}#app.login-app .centered{display:flex;text-align:center;align-items:center;justify-content:center;width:100%}#app.login-app .title{font-size:2rem;margin-block-end:2rem}#app.login-app .title b{font-weight:bold!important}#app.login-app{overflow:hidden}#app.login-app #login{animation:charge 0.5s both;background-color:#fff;border-radius:2rem;padding:4rem 3rem;transition:all 0.3s;user-select:none;-webkit-user-select:none;-moz-user-select:none}#app.login-app #login:hover{box-shadow:0 2px 8px rgb(0 0 0 / .09)}@keyframes charge{from{transform:translateY(5rem);opacity:0}to{transform:translateY(0);opacity:1}}#app.login-app .under{background-color:#c7ebe2;z-index:0}#app.login-app.dark .under{background-color:var(--dark-color-login-wave)}#app.login-app.dark #login{background-color:var(--dark-color-surface-100)}#app.login-app.dark h1{color:#fff}#app.login-app .ant-btn-primary-login{width:100%}#app.login-app .ant-btn-primary-login:focus,#app.login-app .ant-btn-primary-login:hover{color:#fff;background-color:#065;border-color:#065;background-image:linear-gradient(270deg,#fff0 30%,#009980,#fff0 100%);background-repeat:no-repeat;animation:ma-bg-move ease-in-out 5s infinite;background-position-x:-500px;width:95%;animation-delay:-0.5s;box-shadow:0 2px 0 rgb(0 0 0 / .045)}#app.login-app .ant-btn-primary-login.active,#app.login-app .ant-btn-primary-login:active{color:#fff;background-color:#065;border-color:#065}@keyframes ma-bg-move{0%{background-position:-500px 0}50%{background-position:1000px 0}100%{background-position:1000px 0}}#app.login-app .wave-btn-bg{position:relative;border-radius:25px;width:100%;transition:all 0.3s cubic-bezier(.645,.045,.355,1)}#app.login-app.dark .wave-btn-bg{color:#fff;position:relative;background-color:#0a7557;border:2px double #fff0;background-origin:border-box;background-clip:padding-box,border-box;background-size:300%;width:100%;z-index:1}#app.login-app.dark .wave-btn-bg:hover{animation:wave-btn-tara 4s ease infinite}#app.login-app.dark .wave-btn-bg-cl{background-image:linear-gradient(#fff0,#fff0),radial-gradient(circle at left top,#006655,#009980,#006655)!important;border-radius:3em}#app.login-app.dark .wave-btn-bg-cl:hover{width:95%}#app.login-app.dark .wave-btn-bg-cl:before{position:absolute;content:"";top:-5px;left:-5px;bottom:-5px;right:-5px;z-index:-1;background:inherit;background-size:inherit;border-radius:4em;opacity:0;transition:0.5s}#app.login-app.dark .wave-btn-bg-cl:hover::before{opacity:1;filter:blur(20px);animation:wave-btn-tara 8s linear infinite}@keyframes wave-btn-tara{to{background-position:300%}}#app.login-app.dark .ant-btn-primary-login{font-size:14px;color:#fff;text-align:center;background-image:linear-gradient(rgb(13 14 33 / .45),rgb(13 14 33 / .35));border-radius:2rem;border:none;outline:none;background-color:#fff0;height:46px;position:relative;white-space:nowrap;cursor:pointer;touch-action:manipulation;padding:0 15px;width:100%;animation:none;background-position-x:0;box-shadow:none}#app.login-app .waves-header{position:fixed;width:100%;text-align:center;background-color:#dbf5ed;color:#fff;z-index:-1}#app.login-app.dark .waves-header{background-color:var(--dark-color-login-background)}#app.login-app .waves-inner-header{height:50vh;width:100%;margin:0;padding:0}#app.login-app .waves{position:relative;width:100%;height:15vh;margin-bottom:-8px;min-height:100px;max-height:150px}#app.login-app .parallax>use{animation:move-forever 25s cubic-bezier(.55,.5,.45,.5) infinite}#app.login-app.dark .parallax>use{fill:var(--dark-color-login-wave)}#app.login-app .parallax>use:nth-child(1){animation-delay:-2s;animation-duration:4s;opacity:.2}#app.login-app .parallax>use:nth-child(2){animation-delay:-3s;animation-duration:7s;opacity:.4}#app.login-app .parallax>use:nth-child(3){animation-delay:-4s;animation-duration:10s;opacity:.6}#app.login-app .parallax>use:nth-child(4){animation-delay:-5s;animation-duration:13s}@keyframes move-forever{0%{transform:translate3d(-90px,0,0)}100%{transform:translate3d(85px,0,0)}}@media (max-width:768px){#app.login-app .waves{height:40px;min-height:40px}}#app.login-app .words-wrapper{width:100%;display:inline-block;position:relative;text-align:center}#app.login-app .words-wrapper b{width:100%;display:inline-block;position:absolute;left:0;top:0}#app.login-app .words-wrapper b.is-visible{position:relative}#app.login-app .headline.zoom .words-wrapper{-webkit-perspective:300px;-moz-perspective:300px;perspective:300px}#app.login-app .headline{display:flex;justify-content:center;align-items:center}#app.login-app .headline.zoom b{opacity:0}#app.login-app .headline.zoom b.is-visible{opacity:1;-webkit-animation:zoom-in 0.8s;-moz-animation:zoom-in 0.8s;animation:cubic-bezier(.215,.61,.355,1) zoom-in 0.8s}#app.login-app .headline.zoom b.is-hidden{-webkit-animation:zoom-out 0.8s;-moz-animation:zoom-out 0.8s;animation:cubic-bezier(.215,.61,.355,1) zoom-out 0.4s}@-webkit-keyframes zoom-in{0%{opacity:0;-webkit-transform:translateZ(100px)}100%{opacity:1;-webkit-transform:translateZ(0)}}@-moz-keyframes zoom-in{0%{opacity:0;-moz-transform:translateZ(100px)}100%{opacity:1;-moz-transform:translateZ(0)}}@keyframes zoom-in{0%{opacity:0;-webkit-transform:translateZ(100px);-moz-transform:translateZ(100px);-ms-transform:translateZ(100px);-o-transform:translateZ(100px);transform:translateZ(100px)}100%{opacity:1;-webkit-transform:translateZ(0);-moz-transform:translateZ(0);-ms-transform:translateZ(0);-o-transform:translateZ(0);transform:translateZ(0)}}@-webkit-keyframes zoom-out{0%{opacity:1;-webkit-transform:translateZ(0)}100%{opacity:0;-webkit-transform:translateZ(-100px)}}@-moz-keyframes zoom-out{0%{opacity:1;-moz-transform:translateZ(0)}100%{opacity:0;-moz-transform:translateZ(-100px)}}@keyframes zoom-out{0%{opacity:1;-webkit-transform:translateZ(0);-moz-transform:translateZ(0);-ms-transform:translateZ(0);-o-transform:translateZ(0);transform:translateZ(0)}100%{opacity:0;-webkit-transform:translateZ(-100px);-moz-transform:translateZ(-100px);-ms-transform:translateZ(-100px);-o-transform:translateZ(-100px);transform:translateZ(-100px)}}#app.login-app .setting-section{position:absolute;top:0;right:0;padding:22px}#app.login-app .ant-space-item .ant-switch{margin:2px 0 4px}#app.login-app .ant-layout-content{transition:none}.inbounds-page .ant-table:not(.ant-table-expanded-row .ant-table){outline:1px solid #f0f0f0;outline-offset:-1px;border-radius:1rem;overflow-x:hidden}.inbounds-page.dark .ant-table:not(.ant-table-expanded-row .ant-table){outline-color:var(--dark-color-table-ring)}.inbounds-page .ant-table .ant-table-content .ant-table-scroll .ant-table-body{overflow-y:hidden}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper{margin:-10px 22px!important}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper .ant-table{border-bottom-left-radius:1rem;border-bottom-right-radius:1rem}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child tr:last-child td{border-bottom-color:#fff0}.inbounds-page .ant-table .ant-table-tbody tr:last-child.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody>tr:last-child>td:first-child{border-bottom-left-radius:6px}.inbounds-page .ant-table .ant-table-tbody tr:last-child.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody>tr:last-child>td:last-child{border-bottom-right-radius:6px}@media (min-width:769px){.inbounds-page .ant-layout-content{margin:24px 16px}}@media (max-width:768px){.inbounds-page .ant-card-body{padding:.5rem}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper{margin:-10px 2px!important}}.inbounds-page.dark~div .ant-switch-small:not(.ant-switch-checked){background-color:var(--dark-color-surface-100)}.inbounds-page .ant-custom-popover-title{display:flex;align-items:center;gap:10px;margin:5px 0}.inbounds-page .ant-col-sm-24{margin:.5rem -2rem .5rem 2rem}.inbounds-page tr.hideExpandIcon .ant-table-row-expand-icon{display:none}.inbounds-page .infinite-tag,.inbounds-page~div .infinite-tag{padding:0 5px;border-radius:2rem;min-width:50px;min-height:22px}.inbounds-page .infinite-bar .ant-progress-inner .ant-progress-bg{background-color:#F2EAF1;border:#D5BED2 solid 1px}.inbounds-page.dark .infinite-bar .ant-progress-inner .ant-progress-bg{background-color:#7a316f!important;border:#7a316f solid 1px}.inbounds-page~div .ant-collapse{margin:5px 0}.inbounds-page .info-large-tag,.inbounds-page~div .info-large-tag{max-width:200px;overflow:hidden}.inbounds-page .client-comment{font-size:12px;opacity:.75;cursor:help}.inbounds-page .client-email{font-weight:500}.inbounds-page .client-popup-item{display:flex;align-items:center;gap:5px}.inbounds-page .online-animation .ant-badge-status-dot{animation:onlineAnimation 1.2s linear infinite}@keyframes onlineAnimation{0%,50%,100%{transform:scale(1);opacity:1}10%{transform:scale(1.5);opacity:.2}}.inbounds-page .tr-table-box{display:flex;gap:4px;justify-content:center;align-items:center}.inbounds-page .tr-table-rt{flex-basis:70px;min-width:70px;text-align:end}.inbounds-page .tr-table-lt{flex-basis:70px;min-width:70px;text-align:start}.inbounds-page .tr-table-bar{flex-basis:160px;min-width:60px}.inbounds-page .tr-infinity-ch{font-size:14pt;max-height:24px;display:inline-flex;align-items:center}.inbounds-page .ant-table-expanded-row .ant-table .ant-table-body{overflow-x:hidden}.inbounds-page .ant-table-expanded-row .ant-table-tbody>tr>td{padding:10px 2px}.inbounds-page .ant-table-expanded-row .ant-table-thead>tr>th{padding:12px 2px}.idx-cpu-history-svg{display:block;overflow:unset!important}.dark .idx-cpu-history-svg .cpu-grid-line{stroke:rgb(255 255 255 / .08)}.dark .idx-cpu-history-svg .cpu-grid-h-line{stroke:rgb(255 255 255 / .25)}.dark .idx-cpu-history-svg .cpu-grid-y-text,.dark .idx-cpu-history-svg .cpu-grid-x-text{fill:rgb(200 200 200 / .8)}.idx-cpu-history-svg .cpu-grid-text{stroke-width:3;paint-order:stroke;stroke:rgb(0 0 0 / .05)}.dark .idx-cpu-history-svg .cpu-grid-text{fill:#fff;stroke:rgb(0 0 0 / .35)}.inbounds-page~div #inbound-modal form textarea.ant-input{margin:4px 0}@media (min-width:769px){.settings-page .ant-layout-content{margin:24px 16px}}@media (max-width:768px){.settings-page .ant-tabs-nav .ant-tabs-tab{margin:0;padding:12px .5rem}}.settings-page .ant-tabs-bar{margin:0}.settings-page .ant-list-item{display:block}.settings-page .alert-msg{color:#c27512;font-weight:400;font-size:16px;padding:.5rem 1rem;text-align:center;background:rgb(255 145 0 / 15%);margin:1.5rem 2.5rem 0rem;border-radius:.5rem;transition:all 0.5s;animation:settings-page-signal 3s cubic-bezier(.18,.89,.32,1.28) infinite}.settings-page .alert-msg:hover{cursor:default;transition-duration:.3s;animation:settings-page-signal 0.9s ease infinite}@keyframes settings-page-signal{0%{box-shadow:0 0 0 0 rgb(194 118 18 / .5)}50%{box-shadow:0 0 0 6px #fff0}100%{box-shadow:0 0 0 6px #fff0}}.settings-page .alert-msg>i{color:inherit;font-size:24px}.settings-page.dark .ant-input-password-icon{color:var(--dark-color-text-primary)}.settings-page .ant-collapse-content-box .ant-alert{margin-block-end:12px}@media (min-width:769px){.xray-page .ant-layout-content{margin:24px 16px}}@media (max-width:768px){.xray-page .ant-tabs-nav .ant-tabs-tab{margin:0;padding:12px .5rem}.xray-page .ant-table-thead>tr>th,.xray-page .ant-table-tbody>tr>td{padding:10px 0}}.xray-page .ant-tabs-bar{margin:0}.xray-page .ant-list-item{display:block}.xray-page .ant-list-item>li{padding:10px 20px!important}.xray-page .ant-collapse-content-box .ant-alert{margin-block-end:12px}#app.login-app #login input.ant-input:-webkit-autofill{-webkit-box-shadow:0 0 0 100px #f8f8f8 inset;box-shadow:0 0 0 100px #f8f8f8 inset;transition:background-color 9999s ease-in-out 0s,color 9999s ease-in-out 0s;background-clip:text}#app.login-app #login .ant-input-affix-wrapper:hover .ant-input:-webkit-autofill:not(.ant-input-disabled),#app.login-app #login input.ant-input:-webkit-autofill:hover,#app.login-app #login input.ant-input:-webkit-autofill:focus{-webkit-box-shadow:0 0 0 100px #e8f4f2 inset;box-shadow:0 0 0 100px #e8f4f2 inset}#app.login-app.dark #login .ant-input-affix-wrapper:hover .ant-input:-webkit-autofill:not(.ant-input-disabled),#app.login-app.dark #login input.ant-input:-webkit-autofill{-webkit-text-fill-color:var(--dark-color-text-primary);caret-color:var(--dark-color-text-primary);-webkit-box-shadow:0 0 0 1000px var(--dark-color-surface-200) inset;box-shadow:0 0 0 1000px var(--dark-color-surface-200) inset;transition:background-color 9999s ease-in-out 0s,color 9999s ease-in-out 0s}#app.login-app.dark #login .ant-input-affix-wrapper:hover .ant-input:-webkit-autofill:not(.ant-input-disabled),#app.login-app.dark #login input.ant-input:-webkit-autofill:hover,#app.login-app.dark #login input.ant-input:-webkit-autofill:focus{border-color:var(--dark-color-surface-300)}.dark .ant-descriptions-bordered .ant-descriptions-item-label{background-color:var(--dark-color-background)}.dark .ant-descriptions-bordered .ant-descriptions-view,.dark .ant-descriptions-bordered .ant-descriptions-row,.dark .ant-descriptions-bordered .ant-descriptions-item-label,.dark .ant-list-bordered{border-color:var(--dark-color-surface-400)}.dark .ant-descriptions-bordered .ant-descriptions-item-label,.dark .ant-descriptions-bordered .ant-descriptions-item-content{color:var(--dark-color-text-primary)}.dark .ant-dropdown-menu{background-color:var(--dark-color-surface-200)}.dark .ant-dropdown-menu .ant-dropdown-menu-item{color:hsl(0 0% 100% / .65)}.dark .ant-dropdown-menu .ant-dropdown-menu-item:hover{background-color:var(--dark-color-surface-600)}.subscription-page .ant-list.ant-list-split.ant-list-bordered{overflow:hidden}.subscription-page .ant-list.ant-list-split.ant-list-bordered .ant-list-item{overflow-x:auto}.subscription-page .ant-btn.ant-btn-primary.ant-btn-lg.ant-dropdown-trigger{border-radius:4rem;padding:0 20px}.subscription-page .subscription-card{margin:2rem 0}.mb-10{margin-bottom:10px}.mb-12{margin-bottom:12px}.mt-5{margin-top:5px}.mr-8{margin-right:8px}.ml-10{margin-left:10px}.mr-05{margin-right:.5rem}.fs-1rem{font-size:1rem}.w-100{width:100%}.w-70{width:70px}.w-95{width:95px}.text-center{text-align:center}.cursor-pointer{cursor:pointer}.float-right{float:right}.va-middle{vertical-align:middle}.d-flex{display:flex}.justify-end{justify-content:flex-end}.max-w-400{max-width:400px;display:inline-block}.ant-space.jc-center{justify-content:center}.min-h-0{min-height:0}.min-h-100vh{min-height:100vh}.h-100{height:100%}.h-50px{height:50px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-hidden{overflow-x:hidden}.overflow-y-hidden{overflow-y:hidden}.overflow-y-auto{overflow-y:auto}.overflow-x-auto{overflow-x:auto}.mt-1rem{margin-top:1rem}.my-3rem{margin-top:3rem;margin-bottom:3rem} \ No newline at end of file +:root{--color-primary-100:#008771;--dark-color-background:#0a1222;--dark-color-surface-100:#151f31;--dark-color-surface-200:#222d42;--dark-color-surface-300:#2c3950;--dark-color-surface-400:rgba(65, 85, 119, 0.5);--dark-color-surface-500:#2c3950;--dark-color-surface-600:#313f5a;--dark-color-surface-700:#111929;--dark-color-surface-700-rgb:17, 25, 41;--dark-color-table-hover:rgba(44, 57, 80, 0.2);--dark-color-text-primary:rgba(255, 255, 255, 0.75);--dark-color-stroke:#2c3950;--dark-color-btn-danger:#cd3838;--dark-color-btn-danger-border:transparent;--dark-color-btn-danger-hover:#e94b4b;--dark-color-tag-bg:rgba(255, 255, 255, 0.05);--dark-color-tag-border:rgba(255, 255, 255, 0.15);--dark-color-tag-color:rgba(255, 255, 255, 0.75);--dark-color-tag-green-bg:17, 36, 33;--dark-color-tag-green-border:25, 81, 65;--dark-color-tag-green-color:#3ad3ba;--dark-color-tag-purple-bg:#201425;--dark-color-tag-purple-border:#5a2969;--dark-color-tag-purple-color:#d988cd;--dark-color-tag-red-bg:#291515;--dark-color-tag-red-border:#5c2626;--dark-color-tag-red-color:#e04141;--dark-color-tag-orange-bg:#312313;--dark-color-tag-orange-border:#593914;--dark-color-tag-orange-color:#ffa031;--dark-color-tag-blue-bg:#111a2c;--dark-color-tag-blue-border:#1348ab;--dark-color-tag-blue-color:#529fff;--dark-color-codemirror-line-hover:rgba(0, 135, 113, 0.2);--dark-color-codemirror-line-selection:rgba(0, 135, 113, 0.3);--dark-color-login-background:var(--dark-color-background);--dark-color-login-wave:var(--dark-color-surface-200);--dark-color-tooltip:rgba(61, 76, 104, 0.9);--dark-color-back-top:rgba(61, 76, 104, 0.9);--dark-color-back-top-hover:rgba(61, 76, 104, 1);--dark-color-scrollbar:#313f5a;--dark-color-scrollbar-webkit:#7484a0;--dark-color-scrollbar-webkit-hover:#90a4c7;--dark-color-table-ring:rgb(38 52 77);--dark-color-spin-container:#151f31}html[data-theme-animations='off']{.ant-menu,.ant-layout-sider,.ant-card,.ant-tag,.ant-progress-circle>*,.ant-input,.ant-table-row-expand-icon,.ant-switch,.ant-table-thead>tr>th,.ant-select-selection,.ant-btn,.ant-input-number,.ant-input-group-addon,.ant-checkbox-inner,.ant-progress-bg,.ant-progress-success-bg,.ant-radio-button-wrapper:not(:first-child):before,.ant-radio-button-wrapper,#login,.cm-s-xq.CodeMirror{transition:border 0s,background 0s!important}.ant-menu.ant-menu-inline .ant-menu-item:not(.ant-menu-sub .ant-menu-item),.ant-layout-sider-trigger,.ant-alert-close-icon .anticon-close,.ant-tabs-nav .ant-tabs-tab,.ant-input-number-input,.ant-collapse>.ant-collapse-item>.ant-collapse-header,.Line-Hover,.ant-menu-theme-switch,.ant-menu-submenu-title{transition:color 0s!important}.wave-btn-bg{transition:width 0s!important}}html[data-theme='ultra-dark']{--dark-color-background:#21242a;--dark-color-surface-100:#0c0e12;--dark-color-surface-200:#222327;--dark-color-surface-300:#32353b;--dark-color-surface-400:rgba(255, 255, 255, 0.1);--dark-color-surface-500:#3b404b;--dark-color-surface-600:#505663;--dark-color-surface-700:#101113;--dark-color-surface-700-rgb:16, 17, 19;--dark-color-table-hover:rgba(89, 89, 89, 0.15);--dark-color-text-primary:rgb(255 255 255 / 85%);--dark-color-stroke:#202025;--dark-color-tag-green-bg:17, 36, 33;--dark-color-tag-green-border:29, 95, 77;--dark-color-tag-green-color:#59cbac;--dark-color-tag-purple-bg:#241121;--dark-color-tag-purple-border:#5a2969;--dark-color-tag-purple-color:#d686ca;--dark-color-tag-red-bg:#2a1215;--dark-color-tag-red-border:#58181c;--dark-color-tag-red-color:#e84749;--dark-color-tag-orange-bg:#2b1d11;--dark-color-tag-orange-border:#593815;--dark-color-tag-orange-color:#e89a3c;--dark-color-tag-blue-bg:#111a2c;--dark-color-tag-blue-border:#0f367e;--dark-color-tag-blue-color:#3c89e8;--dark-color-codemirror-line-hover:rgba(82, 84, 94, 0.2);--dark-color-codemirror-line-selection:rgba(82, 84, 94, 0.3);--dark-color-login-background:#0a2227;--dark-color-login-wave:#0f2d32;--dark-color-tooltip:rgba(88, 93, 100, 0.9);--dark-color-back-top:rgba(88, 93, 100, 0.9);--dark-color-back-top-hover:rgba(88, 93, 100, 1);--dark-color-scrollbar:rgb(107,107,107);--dark-color-scrollbar-webkit:#9f9f9f;--dark-color-scrollbar-webkit-hover:#d1d1d1;--dark-color-table-ring:rgb(37 39 42);--dark-color-spin-container:#1d1d1d;.ant-dropdown-menu-dark,.dark .ant-dropdown-menu{background-color:var(--dark-color-surface-500)}.dark .ant-dropdown-menu-submenu-title:hover,.dark .ant-select-dropdown-menu-item-active:not(.ant-select-dropdown-menu-item-disabled),.dark .ant-select-dropdown-menu-item:hover:not(.ant-select-dropdown-menu-item-disabled){background-color:rgb(0 93 78 / .3)}.dark .waves-header{background-color:#0a2227}.dark .ant-calendar-year-panel-year:hover,.dark .ant-calendar-month-panel-month:hover,.dark .ant-calendar-decade-panel-decade:hover{background-color:var(--dark-color-surface-600)}}html,body{height:100vh;width:100vw;margin:0;padding:0;overflow:hidden}body{color:rgb(0 0 0 / .65);font-size:14px;font-variant:tabular-nums;line-height:1.5;background-color:#fff;font-feature-settings:"tnum"}html{--antd-wave-shadow-color:var(--color-primary-100);line-height:1.15;text-size-adjust:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-moz-tap-highlight-color:#fff0;-webkit-tap-highlight-color:#fff0}@supports (scrollbar-width:auto) and (not selector(::-webkit-scrollbar)){:not(.dark){scrollbar-color:#9a9a9a #fff0;scrollbar-width:thin}.dark *{scrollbar-color:var(--dark-color-scrollbar) #fff0;scrollbar-width:thin}}::-webkit-scrollbar{width:10px;height:10px;background-color:#fff0}::-webkit-scrollbar-track{background-color:#fff0;margin-block:.5em}.ant-modal-wrap::-webkit-scrollbar-track{background-color:#fff;margin-block:0}::-webkit-scrollbar-thumb{border-radius:9999px;background-color:#9a9a9a;border:2px solid #fff0;background-clip:content-box}::-webkit-scrollbar-thumb:hover,::-webkit-scrollbar-thumb:active{background-color:#828282}.dark .ant-modal-wrap::-webkit-scrollbar-track{background-color:var(--dark-color-background)}.dark::-webkit-scrollbar-thumb{background-color:var(--dark-color-scrollbar-webkit)}.dark::-webkit-scrollbar-thumb:hover,.dark::-webkit-scrollbar-thumb:active{background-color:var(--dark-color-scrollbar-webkit-hover)}::-moz-selection{color:var(--color-primary-100);background-color:#cfe8e4}::selection{color:var(--color-primary-100);background-color:#cfe8e4}#app{height:100%;position:fixed;top:0;left:0;right:0;bottom:0;margin:0;padding:0;overflow:auto}.ant-layout,.ant-layout *{box-sizing:border-box}.ant-spin-container:after{border-radius:1.5rem}.dark .ant-spin-container:after{background:var(--dark-color-spin-container)}style attribute{text-align:center}.ant-table-thead>tr>th{padding:12px 8px}.ant-table-tbody>tr>td{padding:10px 8px}.ant-table-thead>tr>th{color:rgb(0 0 0 / .85);font-weight:500;text-align:left;border-bottom:1px solid #e8e8e8;transition:background 0.3s ease}.ant-table table{border-radius:1rem}.ant-table-bordered .ant-table-tbody:not(.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody)>tr:last-child>td:first-child{border-bottom-left-radius:1rem}.ant-table-bordered .ant-table-tbody:not(.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody)>tr:last-child>td:last-child{border-bottom-right-radius:1rem}.ant-table{box-sizing:border-box;margin:0;padding:0;color:rgb(0 0 0 / .65);font-size:14px;font-variant:tabular-nums;line-height:1.5;list-style:none;font-feature-settings:"tnum";position:relative;clear:both}.ant-table .ant-table-body:not(.ant-table-expanded-row .ant-table-body){overflow-x:auto!important}.ant-card-hoverable{cursor:auto;cursor:pointer}.ant-card{box-sizing:border-box;margin:0;padding:0;color:rgb(0 0 0 / .65);font-size:14px;font-variant:tabular-nums;line-height:1.5;list-style:none;position:relative;background-color:#fff;border-radius:2px;transition:all 0.3s}.ant-space{width:100%}.ant-layout-sider-zero-width-trigger{display:none}@media (max-width:768px){.ant-layout-sider{display:none}.ant-card,.ant-alert-error{margin:.5rem}.ant-tabs{margin:.5rem;padding:.5rem}.ant-modal-body{padding:20px}.ant-form-item-label{line-height:1.5;padding:8px 0 0}:not(.dark)::-webkit-scrollbar{width:8px;height:8px;background-color:#fff0}.dark::-webkit-scrollbar{width:8px;height:8px;background-color:#fff0}}.ant-layout-content{min-height:auto}.ant-card,.ant-tabs{border-radius:1.5rem}.ant-card-hoverable{cursor:auto}.ant-card+.ant-card{margin-top:20px}.drawer-handle{position:absolute;top:72px;width:41px;height:40px;cursor:pointer;z-index:0;text-align:center;line-height:40px;font-size:16px;display:flex;justify-content:center;align-items:center;background-color:#fff;right:-40px;box-shadow:2px 0 8px rgb(0 0 0 / .15);border-radius:0 4px 4px 0}.ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected{background-color:#006655!important;background-image:linear-gradient(270deg,#fff0 30%,#009980,#fff0 100%);background-repeat:no-repeat;animation:ma-bg-move linear 6.6s infinite;color:#fff;border-radius:.5rem}.ant-layout-sider-collapsed .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected{border-radius:0}.ant-menu-item-active,.ant-menu-item:hover,.ant-menu-submenu-title:hover,.ant-menu-item:active,.ant-menu-submenu-title:active{color:var(--color-primary-100);background-color:#e8f4f2}.ant-menu-inline .ant-menu-item,.ant-menu-inline .ant-menu-submenu-title{border-radius:.5rem}.ant-menu-inline .ant-menu-item:after,.ant-menu{border-right-width:0}.ant-layout-sider-children,.ant-pagination ul{padding:.5rem}.ant-layout-sider-collapsed .ant-layout-sider-children{padding:.5rem 0}.ant-dropdown-menu,.ant-select-dropdown-menu{padding:.5rem}.ant-dropdown-menu-item,.ant-dropdown-menu-item:hover,.ant-select-dropdown-menu-item,.ant-select-dropdown-menu-item:hover,.ant-select-selection--multiple .ant-select-selection__choice{border-radius:.5rem}.ant-select-dropdown--multiple .ant-select-dropdown-menu .ant-select-dropdown-menu-item,.ant-select-dropdown--single .ant-select-dropdown-menu .ant-select-dropdown-menu-item-selected{margin-block:2px}@media (min-width:769px){.drawer-handle{display:none}.ant-tabs{padding:2rem}}.fade-in-enter,.fade-in-leave-active,.fade-in-linear-enter,.fade-in-linear-leave,.fade-in-linear-leave-active,.fade-in-linear-enter,.fade-in-linear-leave,.fade-in-linear-leave-active{opacity:0}.fade-in-linear-enter-active,.fade-in-linear-leave-active{-webkit-transition:opacity 0.2s linear;transition:opacity 0.2s linear}.fade-in-linear-enter-active,.fade-in-linear-leave-active{-webkit-transition:opacity 0.2s linear;transition:opacity 0.2s linear}.fade-in-enter-active,.fade-in-leave-active{-webkit-transition:all 0.3s cubic-bezier(.55,0,.1,1);transition:all 0.3s cubic-bezier(.55,0,.1,1)}.zoom-in-center-enter-active,.zoom-in-center-leave-active{-webkit-transition:all 0.3s cubic-bezier(.55,0,.1,1);transition:all 0.3s cubic-bezier(.55,0,.1,1)}.zoom-in-center-enter,.zoom-in-center-leave-active{opacity:0;-webkit-transform:scaleX(0);transform:scaleX(0)}.zoom-in-top-enter-active,.zoom-in-top-leave-active{opacity:1;-webkit-transform:scaleY(1);transform:scaleY(1);-webkit-transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:center top;transform-origin:center top}.zoom-in-top-enter,.zoom-in-top-leave-active{opacity:0;-webkit-transform:scaleY(0);transform:scaleY(0)}.zoom-in-bottom-enter-active,.zoom-in-bottom-leave-active{opacity:1;-webkit-transform:scaleY(1);transform:scaleY(1);-webkit-transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:center bottom;transform-origin:center bottom}.zoom-in-bottom-enter,.zoom-in-bottom-leave-active{opacity:0;-webkit-transform:scaleY(0);transform:scaleY(0)}.zoom-in-left-enter-active,.zoom-in-left-leave-active{opacity:1;-webkit-transform:scale(1,1);transform:scale(1,1);-webkit-transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:top left;transform-origin:top left}.zoom-in-left-enter,.zoom-in-left-leave-active{opacity:0;-webkit-transform:scale(.45,.45);transform:scale(.45,.45)}.list-enter-active,.list-leave-active{-webkit-transition:all 0.3s;transition:all 0.3s}.list-enter,.list-leave-active{opacity:0;-webkit-transform:translateY(-30px);transform:translateY(-30px)}.ant-tooltip-inner{min-height:0;padding-inline:1rem}.ant-list-item-meta-title{font-size:14px}.ant-progress-inner{background-color:#ebeef5}.deactive-client .ant-collapse-header{color:#ffffff!important;background-color:#ff7f7f}.ant-table-expand-icon-th,.ant-table-row-expand-icon-cell{width:30px;min-width:30px}.ant-tabs{background-color:#fff}.ant-form-item{margin-bottom:0}.ant-setting-textarea{margin-top:1.5rem}.client-table-header{background-color:#f0f2f5}.client-table-odd-row{background-color:#fafafa}.ant-table-pagination.ant-pagination{float:left}.ant-tag{margin-right:0;margin-inline:2px;display:inline-flex;align-items:center;justify-content:space-evenly}.ant-tag:not(.qr-tag){column-gap:4px}#inbound-info-modal .ant-tag{margin-block:2px}.tr-info-table{display:inline-table;margin-block:10px;width:100%}#inbound-info-modal .tr-info-table .ant-tag{margin-block:0;margin-inline:0}.tr-info-row{display:flex;flex-direction:column;row-gap:2px;margin-block:10px}.tr-info-row a{margin-left:6px}.tr-info-row code{padding-inline:8px;max-height:80px;overflow-y:auto}.tr-info-tag{max-width:100%;text-wrap:balance;overflow:hidden;overflow-wrap:anywhere}.tr-info-title{display:inline-flex;align-items:center;justify-content:flex-start;column-gap:4px}.ant-tag-blue{background-color:#edf4fa;border-color:#a9c5e7;color:#0e49b5}.ant-tag-green{background-color:#eafff9;border-color:#76ccb4;color:#199270}.ant-tag-purple{background-color:#f2eaf1;border-color:#d5bed2;color:#7a316f}.ant-tag-orange,.ant-alert-warning{background-color:#ffeee1;border-color:#fec093;color:#f37b24}.ant-tag-red,.ant-alert-error{background-color:#ffe9e9;border-color:#ff9e9e;color:#cf3c3c}.ant-input::placeholder{opacity:.5}.ant-input:hover,.ant-input:focus{background-color:#e8f4f2}.ant-input-affix-wrapper:hover .ant-input:not(.ant-input-disabled){background-color:#e8f4f2}.delete-icon:hover{color:#e04141}.normal-icon:hover{color:var(--color-primary-100)}.dark ::-moz-selection{color:#fff;background-color:var(--color-primary-100)}.dark ::selection{color:#fff;background-color:var(--color-primary-100)}.dark .normal-icon:hover{color:#fff}.dark .ant-layout-sider,.dark .ant-drawer-content,.ant-menu-dark,.ant-menu-dark .ant-menu-sub,.dark .ant-card,.dark .ant-table,.dark .ant-collapse-content,.dark .ant-tabs{background-color:var(--dark-color-surface-100);color:var(--dark-color-text-primary)}.dark .ant-card-hoverable:hover,.dark .ant-space-item>.ant-tabs:hover{box-shadow:0 2px 8px #fff0}.dark>.ant-layout,.dark .drawer-handle,.dark .ant-table-thead>tr>th,.dark .ant-table-expanded-row,.dark .ant-table-expanded-row:hover,.dark .ant-table-expanded-row .ant-table-tbody,.dark .ant-calendar{background-color:var(--dark-color-background);color:var(--dark-color-text-primary)}.dark .ant-table-expanded-row .ant-table-thead>tr:first-child>th{border-radius:0}.dark .ant-calendar,.dark .ant-card-bordered{border-color:var(--dark-color-background)}.dark .ant-table-bordered,.dark .ant-table-bordered.ant-table-empty .ant-table-placeholder,.dark .ant-table-bordered .ant-table-body>table,.dark .ant-table-bordered .ant-table-fixed-left table,.dark .ant-table-bordered .ant-table-fixed-right table,.dark .ant-table-bordered .ant-table-header>table,.dark .ant-table-bordered .ant-table-thead>tr:not(:last-child)>th,.dark .ant-table-bordered .ant-table-tbody>tr>td,.dark .ant-table-bordered .ant-table-thead>tr>th{border-color:var(--dark-color-surface-400)}.dark .ant-table-tbody>tr>td,.dark .ant-table-thead>tr>th,.dark .ant-card-head,.dark .ant-modal-header,.dark .ant-collapse>.ant-collapse-item,.dark .ant-tabs-bar,.dark .ant-list-split .ant-list-item,.dark .ant-popover-title,.dark .ant-calendar-header,.dark .ant-calendar-input-wrap{border-bottom-color:var(--dark-color-surface-400)}.dark .ant-modal-footer,.dark .ant-collapse-content,.dark .ant-calendar-footer,.dark .ant-divider-horizontal.ant-divider-with-text-left:before,.dark .ant-divider-horizontal.ant-divider-with-text-left:after,.dark .ant-divider-horizontal.ant-divider-with-text-center:before,.dark .ant-divider-horizontal.ant-divider-with-text-center:after{border-top-color:var(--dark-color-surface-300)}.ant-divider-horizontal.ant-divider-with-text-left:before{width:10%}.dark .ant-progress-text,.dark .ant-card-head,.dark .ant-form,.dark .ant-collapse>.ant-collapse-item>.ant-collapse-header,.dark .ant-modal-close-x,.dark .ant-form .anticon,.dark .ant-tabs-tab-arrow-show:not(.ant-tabs-tab-btn-disabled),.dark .anticon-close,.dark .ant-list-item-meta-title,.dark .ant-select-selection i,.dark .ant-modal-confirm-title,.dark .ant-modal-confirm-content,.dark .ant-popover-message,.dark .ant-modal,.dark .ant-divider-inner-text,.dark .ant-popover-title,.dark .ant-popover-inner-content,.dark h2,.dark .ant-modal-title,.dark .ant-form-item-label>label,.dark .ant-checkbox-wrapper,.dark .ant-form-item,.dark .ant-calendar-footer .ant-calendar-today-btn,.dark .ant-calendar-footer .ant-calendar-time-picker-btn,.dark .ant-calendar-day-select,.dark .ant-calendar-month-select,.dark .ant-calendar-year-select,.dark .ant-calendar-date,.dark .ant-calendar-year-panel-year,.dark .ant-calendar-month-panel-month,.dark .ant-calendar-decade-panel-decade{color:var(--dark-color-text-primary)}.dark .ant-pagination-options-size-changer .ant-select-arrow .anticon.anticon-down.ant-select-arrow-icon{color:rgb(255 255 255 / 35%)}.dark .ant-pagination-item a,.dark .ant-pagination-next a,.dark .ant-pagination-prev a{color:var(--dark-color-text-primary)}.dark .ant-pagination-item:focus a,.dark .ant-pagination-item:hover a,.dark .ant-pagination-item-active a,.dark .ant-pagination-next:hover .ant-pagination-item-link{color:var(--color-primary-100)}.dark .ant-pagination-item-active{background-color:#fff0}.dark .ant-list-item-meta-description{color:rgb(255 255 255 / .45)}.dark .ant-pagination-disabled i,.dark .ant-tabs-tab-btn-disabled{color:rgb(255 255 255 / .25)}.dark .ant-input,.dark .ant-input-group-addon,.dark .ant-collapse,.dark .ant-select-selection,.dark .ant-input-number,.dark .ant-input-number-handler-wrap,.dark .ant-table-placeholder,.dark .ant-empty-normal,.dark .ant-select-dropdown,.dark .ant-select-dropdown li,.dark .ant-select-dropdown-menu-item,.dark .client-table-header,.dark .ant-select-selection--multiple .ant-select-selection__choice{background-color:var(--dark-color-surface-200);border-color:var(--dark-color-surface-300);color:var(--dark-color-text-primary)}.dark .ant-select-dropdown--multiple .ant-select-dropdown-menu .ant-select-dropdown-menu-item.ant-select-dropdown-menu-item-selected :not(.ant-dropdown-menu-submenu-title:hover){background-color:var(--dark-color-surface-300)}.dark .ant-select-dropdown-menu-item.ant-select-dropdown-menu-item-selected{background-color:var(--dark-color-surface-300)}.dark .ant-calendar-time-picker-inner{background-color:var(--dark-color-background)}.dark .ant-select-selection:hover,.dark .ant-calendar-picker-clear,.dark .ant-input-number:hover,.dark .ant-input-number:focus,.dark .ant-input:hover,.dark .ant-input:focus{background-color:rgb(0 135 113 / .3);border-color:var(--color-primary-100)}.dark .ant-input-affix-wrapper:hover .ant-input:not(.ant-input-disabled){border-color:var(--color-primary-100);background-color:rgb(0 135 113 / .3)}.dark .ant-btn:not(.ant-btn-primary):not(.ant-btn-danger){color:var(--dark-color-text-primary);background-color:rgb(10 117 87 / 30%);border:1px solid var(--color-primary-100)}.dark .ant-radio-button-wrapper,.dark .ant-radio-button-wrapper:before{color:var(--dark-color-text-primary);background-color:rgb(0 135 113 / .3);border-color:var(--color-primary-100)}.ant-btn:focus:not(.ant-btn-primary):not(.ant-btn-danger),.ant-btn:hover:not(.ant-btn-primary):not(.ant-btn-danger){background-color:#e8f4f2}.dark .ant-btn:focus:not(.ant-btn-primary):not(.ant-btn-danger),.dark .ant-btn:hover:not(.ant-btn-primary):not(.ant-btn-danger){color:#fff;background-color:rgb(10 117 87 / 50%);border-color:var(--color-primary-100)}.dark .ant-btn-primary[disabled],.dark .ant-btn-danger[disabled],.dark .ant-calendar-ok-btn-disabled{color:rgb(255 255 255 / 35%);background-color:var(--dark-color-surface-200);border-color:var(--dark-color-surface-300)}.dark .ant-table-tbody>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.dark .client-table-odd-row{background-color:var(--dark-color-table-hover)}.dark .ant-table-row-expand-icon{color:#fff;background-color:#fff0;border-color:rgb(255 255 255 / 20%)}.dark .ant-table-row-expand-icon:hover{color:var(--color-primary-100);background-color:#fff0;border-color:var(--color-primary-100)}.dark .ant-switch:not(.ant-switch-checked),.dark .ant-progress-line .ant-progress-inner{background-color:var(--dark-color-surface-500)}.dark .ant-progress-circle-trail{stroke:var(--dark-color-stroke)!important}.dark .ant-popover-inner{background-color:var(--dark-color-surface-500)}.dark>.ant-popover-content>.ant-popover-arrow{border-color:var(--dark-color-surface-500)}@media (max-width:768px){.dark .ant-popover-inner{background-color:var(--dark-color-surface-200)}.dark>.ant-popover-content>.ant-popover-arrow{border-color:var(--dark-color-surface-200)}}.ant-dropdown-menu-dark .ant-dropdown-menu-item:hover,.dark .ant-select-dropdown-menu-item-selected,.dark .ant-calendar-time-picker-select-option-selected{background-color:var(--dark-color-surface-600)}.ant-menu-dark .ant-menu-item:hover,.ant-menu-dark .ant-menu-submenu-title:hover{background-color:var(--dark-color-surface-300)}.dark .ant-menu-item:active,.dark .ant-menu-submenu-title:active{color:#fff;background-color:var(--dark-color-surface-300)}.dark .ant-alert-message{color:rgb(255 255 255 / .85)}.dark .ant-tag{color:var(--dark-color-tag-color);background-color:var(--dark-color-tag-bg);border-color:var(--dark-color-tag-border)}.dark .ant-tag-blue{background-color:var(--dark-color-tag-blue-bg);border-color:var(--dark-color-tag-blue-border);color:var(--dark-color-tag-blue-color)}.dark .ant-tag-red,.dark .ant-alert-error{background-color:var(--dark-color-tag-red-bg);border-color:var(--dark-color-tag-red-border);color:var(--dark-color-tag-red-color)}.dark .ant-tag-orange,.dark .ant-alert-warning{background-color:var(--dark-color-tag-orange-bg);border-color:var(--dark-color-tag-orange-border);color:var(--dark-color-tag-orange-color)}.dark .ant-tag-green{background-color:rgb(var(--dark-color-tag-green-bg));border-color:rgb(var(--dark-color-tag-green-border));color:var(--dark-color-tag-green-color)}.dark .ant-tag-purple{background-color:var(--dark-color-tag-purple-bg);border-color:var(--dark-color-tag-purple-border);color:var(--dark-color-tag-purple-color)}.dark .ant-modal-content,.dark .ant-modal-header{background-color:var(--dark-color-surface-700)}.dark .ant-calendar-next-month-btn-day .ant-calendar-date,.dark .ant-calendar-last-month-cell .ant-calendar-date{color:var(--dark-color-surface-300)}.dark .ant-calendar-selected-day .ant-calendar-date{background-color:var(--color-primary-100)!important;color:#fff}.dark .ant-calendar-date:hover,.dark .ant-calendar-time-picker-select li:hover{background-color:var(--dark-color-surface-600);color:#fff}.dark .ant-calendar-header a:hover,.dark .ant-calendar-header a:hover::before,.dark .ant-calendar-header a:hover::after{border-color:#fff}.dark .ant-calendar-time-picker-select{border-right-color:var(--dark-color-surface-300)}.has-warning .ant-select-selection,.has-warning .ant-select-selection:hover,.has-warning .ant-input,.has-warning .ant-input:hover{background-color:#ffeee1;border-color:#fec093}.has-warning .ant-input::placeholder{color:#f37b24}.has-warning .ant-input:not([disabled]):hover{border-color:#fec093}.dark .has-warning .ant-select-selection,.dark .has-warning .ant-select-selection:hover,.dark .has-warning .ant-input,.dark .has-warning .ant-input:hover{border-color:#784e1d;background:#312313}.dark .has-warning .ant-input::placeholder{color:rgb(255 160 49 / 70%)}.dark .has-warning .anticon{color:#ffa031}.dark .has-success .anticon{color:var(--color-primary-100);animation-name:diffZoomIn1!important}.dark .anticon-close-circle{color:#e04141}.dark .ant-spin-nested-loading>div>.ant-spin .ant-spin-text{text-shadow:0 1px 2px #0007}.dark .ant-spin{color:#fff}.dark .ant-spin-dot-item{background-color:#fff}.ant-checkbox-wrapper,.ant-input-group-addon,.ant-tabs-tab,.ant-input::placeholder,.ant-collapse-header,.ant-menu,.ant-radio-button-wrapper{-webkit-user-select:none;user-select:none}.ant-calendar-date,.ant-calendar-year-panel-year,.ant-calendar-decade-panel-decade,.ant-calendar-month-panel-month{border-radius:4px}.ant-checkbox-inner,.ant-checkbox-checked:after,.ant-table-row-expand-icon{border-radius:6px}.ant-calendar-date:hover{background-color:#e8f4f2}.ant-calendar-date:active{background-color:#e8f4f2;color:rgb(0 0 0 / .65)}.ant-calendar-today .ant-calendar-date{color:var(--color-primary-100);font-weight:400;border-color:var(--color-primary-100)}.dark .ant-calendar-today .ant-calendar-date{color:#fff;border-color:var(--color-primary-100)}.ant-calendar-selected-day .ant-calendar-date{background:var(--color-primary-100);color:#fff}li.ant-select-dropdown-menu-item:empty:after{content:"None";font-weight:400;color:rgb(0 0 0 / .25)}.dark li.ant-select-dropdown-menu-item:empty:after{content:"None";font-weight:400;color:rgb(255 255 255 / .3)}.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item:hover .ant-select-selected-icon{color:rgb(0 0 0 / .87)}.dark.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item:hover .ant-select-selected-icon{color:#fff}.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item-selected .ant-select-selected-icon,.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item-selected:hover .ant-select-selected-icon{color:var(--color-primary-100)}.ant-select-selection:hover,.ant-input-number-focused,.ant-input-number:hover{background-color:#e8f4f2}.dark .ant-input-number-handler:active{background-color:var(--color-primary-100)}.dark .ant-input-number-handler:hover .ant-input-number-handler-down-inner,.dark .ant-input-number-handler:hover .ant-input-number-handler-up-inner{color:#fff}.dark .ant-input-number-handler-down{border-top:1px solid rgb(217 217 217 / .3)}.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-century-select,.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-decade-select,.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-month-select,.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-year-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-century-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-decade-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-month-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-year-select{color:rgb(255 255 255 / .85)}.dark .ant-calendar-year-panel-header{border-bottom:1px solid var(--dark-color-surface-200)}.dark .ant-calendar-year-panel-last-decade-cell .ant-calendar-year-panel-year,.dark .ant-calendar-year-panel-next-decade-cell .ant-calendar-year-panel-year{color:rgb(255 255 255 / .35)}.dark .ant-divider:not(.ant-divider-with-text-center,.ant-divider-with-text-left,.ant-divider-with-text-right),.ant-dropdown-menu-dark,.dark .ant-calendar-year-panel-year:hover,.dark .ant-calendar-month-panel-month:hover,.dark .ant-calendar-decade-panel-decade:hover{background-color:var(--dark-color-surface-200)}.dark .ant-calendar-header a:hover{color:#fff}.dark .ant-calendar-month-panel-header{background-color:var(--dark-color-background);border-bottom:1px solid var(--dark-color-surface-200)}.dark .ant-calendar-year-panel,.dark .ant-calendar table{background-color:var(--dark-color-background)}.dark .ant-calendar-year-panel-selected-cell .ant-calendar-year-panel-year,.dark .ant-calendar-year-panel-selected-cell .ant-calendar-year-panel-year:hover,.dark .ant-calendar-month-panel-selected-cell .ant-calendar-month-panel-month,.dark .ant-calendar-month-panel-selected-cell .ant-calendar-month-panel-month:hover,.dark .ant-calendar-decade-panel-selected-cell .ant-calendar-decade-panel-decade,.dark .ant-calendar-decade-panel-selected-cell .ant-calendar-decade-panel-decade:hover{color:#fff;background-color:var(--color-primary-100)!important}.dark .ant-calendar-last-month-cell .ant-calendar-date,.dark .ant-calendar-last-month-cell .ant-calendar-date:hover,.dark .ant-calendar-next-month-btn-day .ant-calendar-date,.dark .ant-calendar-next-month-btn-day .ant-calendar-date:hover{color:rgb(255 255 255 / 25%);background:#fff0;border-color:#fff0}.dark .ant-calendar-today .ant-calendar-date:hover{color:#fff;border-color:var(--color-primary-100);background-color:var(--color-primary-100)}.dark .ant-calendar-decade-panel-last-century-cell .ant-calendar-decade-panel-decade,.dark .ant-calendar-decade-panel-next-century-cell .ant-calendar-decade-panel-decade{color:rgb(255 255 255 / 25%)}.dark .ant-calendar-decade-panel-header{border-bottom:1px solid var(--dark-color-surface-200);background-color:var(--dark-color-background)}.dark .ant-checkbox-inner{background-color:rgb(0 135 113 / .3);border-color:rgb(0 135 113 / .3)}.dark .ant-checkbox-checked .ant-checkbox-inner{background-color:var(--color-primary-100);border-color:var(--color-primary-100)}.dark .ant-calendar-input{background-color:var(--dark-color-background);color:var(--dark-color-text-primary)}.dark .ant-calendar-input::placeholder{color:rgb(255 255 255 / .25)}.ant-input-group.ant-input-group-compact-addon:not(:first-child):not(:last-child),.ant-input-group.ant-input-group-compact-wrap:not(:first-child):not(:last-child),.ant-input-group.ant-input-group-compact>.ant-input:not(:first-child):not(:last-child),.ant-input-number-handler,.ant-input-number-handler-wrap{border-radius:0}.ant-input-number{overflow:clip}.ant-modal-body,.ant-collapse-content>.ant-collapse-content-box{overflow-x:auto}.ant-modal-body{overflow-y:hidden}.ant-calendar-year-panel-year:hover,.ant-calendar-decade-panel-decade:hover,.ant-calendar-month-panel-month:hover,.ant-dropdown-menu-item:hover,.ant-dropdown-menu-submenu-title:hover,.ant-select-dropdown-menu-item-active:not(.ant-select-dropdown-menu-item-disabled),.ant-select-dropdown-menu-item:hover:not(.ant-select-dropdown-menu-item-disabled),.ant-table-tbody>tr.ant-table-row-hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.ant-table-tbody>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.ant-table-thead>tr.ant-table-row-hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.ant-table-thead>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td{background-color:#e8f4f2}.dark .ant-dropdown-menu-submenu-title:hover,.dark .ant-select-dropdown-menu-item-active:not(.ant-select-dropdown-menu-item-disabled),.dark .ant-select-dropdown-menu-item:hover:not(.ant-select-dropdown-menu-item-disabled){background-color:rgb(0 93 78 / .3)}.ant-select-dropdown,.ant-popover-inner{overflow-x:hidden}.ant-popover-inner-content{max-height:450px;overflow-y:auto}@media (max-height:900px){.ant-popover-inner-content{max-height:400px}}@media (max-height:768px){.ant-popover-inner-content{max-height:300px}}@media (max-width:768px){.ant-popover-inner-content{max-height:300px}}.qr-modal{display:flex;align-items:flex-end;gap:10px;flex-direction:column;flex-wrap:wrap;row-gap:24px}.qr-box{width:220px}.qr-cv{width:100%;height:100%}.dark .qr-cv{background-color:#fff;padding:1px;border-radius:0.25rem}.qr-bg{background-color:#fff;display:flex;justify-content:center;align-content:center;padding:.8rem;border-radius:1rem;border:solid 1px #e8e8e8;height:220px;width:220px;transition:all 0.1s}.qr-bg:hover{border-color:#76ccb4;background-color:#eafff9}.qr-bg:hover:active{border-color:#76ccb4;background-color:rgb(197 241 228 / 70%)}.dark .qr-bg{background-color:var(--dark-color-surface-700);border-color:var(--dark-color-surface-300)}.dark .qr-bg:hover{background-color:rgb(var(--dark-color-tag-green-bg));border-color:rgb(var(--dark-color-tag-green-border))}.dark .qr-bg:hover:active{background-color:#17322e}@property --tr-rotate{syntax:'';initial-value:45deg;inherits:false}.qr-bg-sub{background-image:linear-gradient(var(--tr-rotate),#76ccb4,transparent,#d5bed2);display:flex;justify-content:center;align-content:center;padding:1px;border-radius:1rem;height:220px;width:220px}.dark .qr-bg-sub{background-image:linear-gradient(var(--tr-rotate),#195141,transparent,#5a2969)}.qr-bg-sub:hover{animation:tr-rotate-gradient 3.5s linear infinite}@keyframes tr-rotate-gradient{from{--tr-rotate:45deg}to{--tr-rotate:405deg}}.qr-bg-sub-inner{background-color:#fff;padding:.8rem;border-radius:1rem;transition:all 0.1s}.qr-bg-sub-inner:hover{background-color:rgb(255 255 255 / 60%);backdrop-filter:blur(25px)}.qr-bg-sub-inner:hover:active{background-color:rgb(255 255 255 / 30%)}.dark .qr-bg-sub-inner{background-color:rgb(var(--dark-color-surface-700-rgb))}.dark .qr-bg-sub-inner:hover{background-color:rgba(var(--dark-color-surface-700-rgb),.5);backdrop-filter:blur(25px)}.dark .qr-bg-sub-inner:hover:active{background-color:rgba(var(--dark-color-surface-700-rgb),.2)}.qr-tag{text-align:center;margin-bottom:10px;width:100%;overflow:hidden;margin-inline:0}@media (min-width:769px){.qr-modal{flex-direction:row;max-width:680px}}.tr-marquee{justify-content:flex-start}.tr-marquee span{padding-right:25%;white-space:nowrap;transform-origin:center}@keyframes move-ltr{0%{transform:translateX(0)}100%{transform:translateX(-100%)}}.ant-input-group-addon:not(:first-child):not(:last-child){border-radius:0rem 1rem 1rem 0rem}b,strong{font-weight:500}.ant-collapse>.ant-collapse-item>.ant-collapse-header{padding:10px 16px 10px 40px}.dark .ant-message-notice-content{background-color:var(--dark-color-surface-200);border:1px solid var(--dark-color-surface-300);color:var(--dark-color-text-primary)}.ant-btn-danger{background-color:var(--dark-color-btn-danger);border-color:var(--dark-color-btn-danger-border)}.ant-btn-danger:focus,.ant-btn-danger:hover{background-color:var(--dark-color-btn-danger-hover);border-color:var(--dark-color-btn-danger-hover)}.dark .ant-alert-close-icon .anticon-close:hover{color:#fff}.ant-empty-small{margin:4px 0;background-color:transparent!important}.ant-empty-small .ant-empty-image{height:20px}.ant-menu-theme-switch,.ant-menu-theme-switch:hover{background-color:transparent!important;cursor:default!important}.dark .ant-tooltip-inner,.dark .ant-tooltip-arrow:before{background-color:var(--dark-color-tooltip)}.ant-select-sm .ant-select-selection__rendered{margin-left:10px}.ant-collapse{-moz-animation:collfade 0.3s ease;-webkit-animation:0.3s collfade 0.3s ease;animation:collfade 0.3s ease}@-webkit-keyframes collfade{0%{transform:scaleY(.8);transform-origin:0% 0%;opacity:0}100%{transform:scaleY(1);transform-origin:0% 0%;opacity:1}}@keyframes collfade{0%{transform:scaleY(.8);transform-origin:0% 0%;opacity:0}100%{transform:scaleY(1);transform-origin:0% 0%;opacity:1}}.ant-table-tbody>tr>td{border-color:#f0f0f0}.ant-table-row-expand-icon{vertical-align:middle;margin-inline-end:8px;position:relative;transform:scale(.9411764705882353)}.ant-table-row-collapsed::before{transform:rotate(-180deg);top:7px;inset-inline-end:3px;inset-inline-start:3px;height:1px;position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-table-row-collapsed::after{transform:rotate(0deg);top:3px;bottom:3px;inset-inline-start:7px;width:1px;position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-table-row-expanded::before{top:7px;inset-inline-end:3px;inset-inline-start:3px;height:1px;position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-table-row-expanded::after{top:3px;bottom:3px;inset-inline-start:7px;width:1px;transform:rotate(90deg);position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-menu-theme-switch.ant-menu-item .ant-switch:not(.ant-switch-disabled):active:after,.ant-switch:not(.ant-switch-disabled):active:before{width:16px}.dark .ant-select-disabled .ant-select-selection{background:var(--dark-color-surface-100);border-color:var(--dark-color-surface-200);color:rgb(255 255 255 / .25)}.dark .ant-select-disabled .anticon{color:rgb(255 255 255 / .25)}.dark .ant-input-number-handler-down-disabled,.dark .ant-input-number-handler-up-disabled{background-color:rgb(0 0 0 / .1)}.dark .ant-input-number-handler-down-disabled .anticon,.dark .ant-input-number-handler-up-disabled .anticon,.dark .ant-input-number-handler-down:hover.ant-input-number-handler-down-disabled .anticon,.dark .ant-input-number-handler-up:hover.ant-input-number-handler-up-disabled .anticon{color:rgb(255 255 255 / .25)}.dark .ant-input-number-handler-down:active.ant-input-number-handler-down-disabled,.dark .ant-input-number-handler-up:active.ant-input-number-handler-up-disabled{background-color:rgb(0 0 0 / .2)}.ant-menu-dark .ant-menu-inline.ant-menu-sub{background:var(--dark-color-surface-100);box-shadow:none}.dark .ant-layout-sider-trigger{background:var(--dark-color-surface-100);color:rgb(255 255 255 / 65%)}.ant-layout-sider{overflow:auto}.dark .ant-back-top-content{background-color:var(--dark-color-back-top)}.dark .ant-back-top-content:hover{background-color:var(--dark-color-back-top-hover)}.ant-calendar-time .ant-calendar-footer .ant-calendar-time-picker-btn{text-transform:capitalize}.ant-calendar{border-color:#fff0;border-width:0}.ant-calendar-time-picker-select li:focus,li.ant-calendar-time-picker-select-option-selected{color:rgb(0 0 0 / .65);font-weight:400;background-color:#e8f4f2}.dark li.ant-calendar-time-picker-select-option-selected{color:var(--dark-color-text-primary);font-weight:400}.dark .ant-calendar-time-picker-select li:focus{color:#fff;font-weight:400;background-color:var(--color-primary-100)}.ant-calendar-time-picker-select li:hover{background:#f5f5f5}.ant-calendar-date{transition:background .3s ease,color .3s ease}li.ant-calendar-time-picker-select-option-selected{margin-block:2px}.ant-calendar-time-picker-select{padding:4px}.ant-calendar-time-picker-select li{height:28px;line-height:28px;border-radius:4px}@media (min-width:769px){.index-page .ant-layout-content{margin:24px 16px}}.index-page .ant-card-dark h2{color:var(--dark-color-text-primary)}.index-page~div .ant-backup-list-item{gap:10px}.index-page~div .ant-version-list-item{--padding:12px;padding:var(--padding)!important;gap:var(--padding)}.index-page.dark~div .ant-version-list-item svg{color:var(--dark-color-text-primary)}.index-page.dark~div .ant-backup-list-item svg,.index-page.dark .ant-badge-status-text,.index-page.dark .ant-card-extra{color:var(--dark-color-text-primary)}.index-page.dark .ant-card-actions>li{color:rgb(255 255 255 / .55)}.index-page.dark~div .ant-radio-inner{background-color:var(--dark-color-surface-100);border-color:var(--dark-color-surface-600)}.index-page.dark~div .ant-radio-checked .ant-radio-inner{border-color:var(--color-primary-100)}.index-page.dark~div .ant-backup-list,.index-page.dark~div .ant-version-list,.index-page.dark .ant-card-actions,.index-page.dark .ant-card-actions>li:not(:last-child){border-color:var(--dark-color-stroke)}.index-page .ant-card-actions{background:#fff0}.index-page .ip-hidden{-webkit-user-select:none;-moz-user-select:none;user-select:none;filter:blur(10px)}.index-page .xray-running-animation .ant-badge-status-dot,.index-page .xray-processing-animation .ant-badge-status-dot{animation:runningAnimation 1.2s linear infinite}.index-page .xray-running-animation .ant-badge-status-processing:after{border-color:var(--color-primary-100)}.index-page .xray-stop-animation .ant-badge-status-processing:after{border-color:#fa8c16}.index-page .xray-error-animation .ant-badge-status-processing:after{border-color:#f5222d}@keyframes runningAnimation{0%,50%,100%{transform:scale(1);opacity:1}10%{transform:scale(1.5);opacity:.2}}.index-page .card-placeholder{text-align:center;padding:30px 0;margin-top:10px;background:#fff0;border:none}.index-page~div .log-container{height:auto;max-height:500px;overflow:auto;margin-top:.5rem}#app.login-app *{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#app.login-app h1{text-align:center;height:110px}#app.login-app .ant-form-item-children .ant-btn,#app.login-app .ant-input{height:50px;border-radius:30px}#app.login-app .ant-input-group-addon{border-radius:0 30px 30px 0;width:50px;font-size:18px}#app.login-app .ant-input-affix-wrapper .ant-input-prefix{left:23px}#app.login-app .ant-input-affix-wrapper .ant-input:not(:first-child){padding-left:50px}#app.login-app .centered{display:flex;text-align:center;align-items:center;justify-content:center;width:100%}#app.login-app .title{font-size:2rem;margin-block-end:2rem}#app.login-app .title b{font-weight:bold!important}#app.login-app{overflow:hidden}#app.login-app #login{animation:charge 0.5s both;background-color:#fff;border-radius:2rem;padding:4rem 3rem;transition:all 0.3s;user-select:none;-webkit-user-select:none;-moz-user-select:none}#app.login-app #login:hover{box-shadow:0 2px 8px rgb(0 0 0 / .09)}@keyframes charge{from{transform:translateY(5rem);opacity:0}to{transform:translateY(0);opacity:1}}#app.login-app .under{background-color:#c7ebe2;z-index:0}#app.login-app.dark .under{background-color:var(--dark-color-login-wave)}#app.login-app.dark #login{background-color:var(--dark-color-surface-100)}#app.login-app.dark h1{color:#fff}#app.login-app .ant-btn-primary-login{width:100%}#app.login-app .ant-btn-primary-login:focus,#app.login-app .ant-btn-primary-login:hover{color:#fff;background-color:#065;border-color:#065;background-image:linear-gradient(270deg,#fff0 30%,#009980,#fff0 100%);background-repeat:no-repeat;animation:ma-bg-move ease-in-out 5s infinite;background-position-x:-500px;width:95%;animation-delay:-0.5s;box-shadow:0 2px 0 rgb(0 0 0 / .045)}#app.login-app .ant-btn-primary-login.active,#app.login-app .ant-btn-primary-login:active{color:#fff;background-color:#065;border-color:#065}@keyframes ma-bg-move{0%{background-position:-500px 0}50%{background-position:1000px 0}100%{background-position:1000px 0}}#app.login-app .wave-btn-bg{position:relative;border-radius:25px;width:100%;transition:all 0.3s cubic-bezier(.645,.045,.355,1)}#app.login-app.dark .wave-btn-bg{color:#fff;position:relative;background-color:#0a7557;border:2px double #fff0;background-origin:border-box;background-clip:padding-box,border-box;background-size:300%;width:100%;z-index:1}#app.login-app.dark .wave-btn-bg:hover{animation:wave-btn-tara 4s ease infinite}#app.login-app.dark .wave-btn-bg-cl{background-image:linear-gradient(#fff0,#fff0),radial-gradient(circle at left top,#006655,#009980,#006655)!important;border-radius:3em}#app.login-app.dark .wave-btn-bg-cl:hover{width:95%}#app.login-app.dark .wave-btn-bg-cl:before{position:absolute;content:"";top:-5px;left:-5px;bottom:-5px;right:-5px;z-index:-1;background:inherit;background-size:inherit;border-radius:4em;opacity:0;transition:0.5s}#app.login-app.dark .wave-btn-bg-cl:hover::before{opacity:1;filter:blur(20px);animation:wave-btn-tara 8s linear infinite}@keyframes wave-btn-tara{to{background-position:300%}}#app.login-app.dark .ant-btn-primary-login{font-size:14px;color:#fff;text-align:center;background-image:linear-gradient(rgb(13 14 33 / .45),rgb(13 14 33 / .35));border-radius:2rem;border:none;outline:none;background-color:#fff0;height:46px;position:relative;white-space:nowrap;cursor:pointer;touch-action:manipulation;padding:0 15px;width:100%;animation:none;background-position-x:0;box-shadow:none}#app.login-app .waves-header{position:fixed;width:100%;text-align:center;background-color:#dbf5ed;color:#fff;z-index:-1}#app.login-app.dark .waves-header{background-color:var(--dark-color-login-background)}#app.login-app .waves-inner-header{height:50vh;width:100%;margin:0;padding:0}#app.login-app .waves{position:relative;width:100%;height:15vh;margin-bottom:-8px;min-height:100px;max-height:150px}#app.login-app .parallax>use{animation:move-forever 25s cubic-bezier(.55,.5,.45,.5) infinite}#app.login-app.dark .parallax>use{fill:var(--dark-color-login-wave)}#app.login-app .parallax>use:nth-child(1){animation-delay:-2s;animation-duration:4s;opacity:.2}#app.login-app .parallax>use:nth-child(2){animation-delay:-3s;animation-duration:7s;opacity:.4}#app.login-app .parallax>use:nth-child(3){animation-delay:-4s;animation-duration:10s;opacity:.6}#app.login-app .parallax>use:nth-child(4){animation-delay:-5s;animation-duration:13s}@keyframes move-forever{0%{transform:translate3d(-90px,0,0)}100%{transform:translate3d(85px,0,0)}}@media (max-width:768px){#app.login-app .waves{height:40px;min-height:40px}}#app.login-app .words-wrapper{width:100%;display:inline-block;position:relative;text-align:center}#app.login-app .words-wrapper b{width:100%;display:inline-block;position:absolute;left:0;top:0}#app.login-app .words-wrapper b.is-visible{position:relative}#app.login-app .headline.zoom .words-wrapper{-webkit-perspective:300px;-moz-perspective:300px;perspective:300px}#app.login-app .headline{display:flex;justify-content:center;align-items:center}#app.login-app .headline.zoom b{opacity:0}#app.login-app .headline.zoom b.is-visible{opacity:1;-webkit-animation:zoom-in 0.8s;-moz-animation:zoom-in 0.8s;animation:cubic-bezier(.215,.61,.355,1) zoom-in 0.8s}#app.login-app .headline.zoom b.is-hidden{-webkit-animation:zoom-out 0.8s;-moz-animation:zoom-out 0.8s;animation:cubic-bezier(.215,.61,.355,1) zoom-out 0.4s}@-webkit-keyframes zoom-in{0%{opacity:0;-webkit-transform:translateZ(100px)}100%{opacity:1;-webkit-transform:translateZ(0)}}@-moz-keyframes zoom-in{0%{opacity:0;-moz-transform:translateZ(100px)}100%{opacity:1;-moz-transform:translateZ(0)}}@keyframes zoom-in{0%{opacity:0;-webkit-transform:translateZ(100px);-moz-transform:translateZ(100px);-ms-transform:translateZ(100px);-o-transform:translateZ(100px);transform:translateZ(100px)}100%{opacity:1;-webkit-transform:translateZ(0);-moz-transform:translateZ(0);-ms-transform:translateZ(0);-o-transform:translateZ(0);transform:translateZ(0)}}@-webkit-keyframes zoom-out{0%{opacity:1;-webkit-transform:translateZ(0)}100%{opacity:0;-webkit-transform:translateZ(-100px)}}@-moz-keyframes zoom-out{0%{opacity:1;-moz-transform:translateZ(0)}100%{opacity:0;-moz-transform:translateZ(-100px)}}@keyframes zoom-out{0%{opacity:1;-webkit-transform:translateZ(0);-moz-transform:translateZ(0);-ms-transform:translateZ(0);-o-transform:translateZ(0);transform:translateZ(0)}100%{opacity:0;-webkit-transform:translateZ(-100px);-moz-transform:translateZ(-100px);-ms-transform:translateZ(-100px);-o-transform:translateZ(-100px);transform:translateZ(-100px)}}#app.login-app .setting-section{position:absolute;top:0;right:0;padding:22px}#app.login-app .ant-space-item .ant-switch{margin:2px 0 4px}#app.login-app .ant-layout-content{transition:none}.inbounds-page .ant-table:not(.ant-table-expanded-row .ant-table){outline:1px solid #f0f0f0;outline-offset:-1px;border-radius:1rem;overflow-x:hidden}.inbounds-page.dark .ant-table:not(.ant-table-expanded-row .ant-table){outline-color:var(--dark-color-table-ring)}.inbounds-page .ant-table .ant-table-content .ant-table-scroll .ant-table-body{overflow-y:hidden}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper{margin:-10px 22px!important}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper .ant-table{border-bottom-left-radius:1rem;border-bottom-right-radius:1rem}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child tr:last-child td{border-bottom-color:#fff0}.inbounds-page .ant-table .ant-table-tbody tr:last-child.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody>tr:last-child>td:first-child{border-bottom-left-radius:6px}.inbounds-page .ant-table .ant-table-tbody tr:last-child.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody>tr:last-child>td:last-child{border-bottom-right-radius:6px}@media (min-width:769px){.inbounds-page .ant-layout-content{margin:24px 16px}}@media (max-width:768px){.inbounds-page .ant-card-body{padding:.5rem}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper{margin:-10px 2px!important}}.inbounds-page.dark~div .ant-switch-small:not(.ant-switch-checked){background-color:var(--dark-color-surface-100)}.inbounds-page .ant-custom-popover-title{display:flex;align-items:center;gap:10px;margin:5px 0}.inbounds-page .ant-col-sm-24{margin:.5rem -2rem .5rem 2rem}.inbounds-page tr.hideExpandIcon .ant-table-row-expand-icon{display:none}.inbounds-page .infinite-tag,.inbounds-page~div .infinite-tag{padding:0 5px;border-radius:2rem;min-width:50px;min-height:22px}.inbounds-page .infinite-bar .ant-progress-inner .ant-progress-bg{background-color:#F2EAF1;border:#D5BED2 solid 1px}.inbounds-page.dark .infinite-bar .ant-progress-inner .ant-progress-bg{background-color:#7a316f!important;border:#7a316f solid 1px}.inbounds-page~div .ant-collapse{margin:5px 0}.inbounds-page .info-large-tag,.inbounds-page~div .info-large-tag{max-width:200px;overflow:hidden}.inbounds-page .client-comment{font-size:12px;opacity:.75;cursor:help}.inbounds-page .client-email{font-weight:500}.inbounds-page .client-popup-item{display:flex;align-items:center;gap:5px}.inbounds-page .online-animation .ant-badge-status-dot{animation:onlineAnimation 1.2s linear infinite}@keyframes onlineAnimation{0%,50%,100%{transform:scale(1);opacity:1}10%{transform:scale(1.5);opacity:.2}}.inbounds-page .tr-table-box{display:flex;gap:4px;justify-content:center;align-items:center}.inbounds-page .tr-table-rt{flex-basis:70px;min-width:70px;text-align:end}.inbounds-page .tr-table-lt{flex-basis:70px;min-width:70px;text-align:start}.inbounds-page .tr-table-bar{flex-basis:160px;min-width:60px}.inbounds-page .tr-infinity-ch{font-size:14pt;max-height:24px;display:inline-flex;align-items:center}.inbounds-page .ant-table-expanded-row .ant-table .ant-table-body{overflow-x:hidden}.inbounds-page .ant-table-expanded-row .ant-table-tbody>tr>td{padding:10px 2px}.inbounds-page .ant-table-expanded-row .ant-table-thead>tr>th{padding:12px 2px}.idx-cpu-history-svg{display:block;overflow:unset!important}.dark .idx-cpu-history-svg .cpu-grid-line{stroke:rgb(255 255 255 / .08)}.dark .idx-cpu-history-svg .cpu-grid-h-line{stroke:rgb(255 255 255 / .25)}.dark .idx-cpu-history-svg .cpu-grid-y-text,.dark .idx-cpu-history-svg .cpu-grid-x-text{fill:rgb(200 200 200 / .8)}.idx-cpu-history-svg .cpu-grid-text{stroke-width:3;paint-order:stroke;stroke:rgb(0 0 0 / .05)}.dark .idx-cpu-history-svg .cpu-grid-text{fill:#fff;stroke:rgb(0 0 0 / .35)}.inbounds-page~div #inbound-modal form textarea.ant-input{margin:4px 0}@media (min-width:769px){.settings-page .ant-layout-content{margin:24px 16px}}@media (max-width:768px){.settings-page .ant-tabs-nav .ant-tabs-tab{margin:0;padding:12px .5rem}}.settings-page .ant-tabs-bar{margin:0}.settings-page .ant-list-item{display:block}.settings-page .alert-msg{color:#c27512;font-weight:400;font-size:16px;padding:.5rem 1rem;text-align:center;background:rgb(255 145 0 / 15%);margin:1.5rem 2.5rem 0rem;border-radius:.5rem;transition:all 0.5s;animation:settings-page-signal 3s cubic-bezier(.18,.89,.32,1.28) infinite}.settings-page .alert-msg:hover{cursor:default;transition-duration:.3s;animation:settings-page-signal 0.9s ease infinite}@keyframes settings-page-signal{0%{box-shadow:0 0 0 0 rgb(194 118 18 / .5)}50%{box-shadow:0 0 0 6px #fff0}100%{box-shadow:0 0 0 6px #fff0}}.settings-page .alert-msg>i{color:inherit;font-size:24px}.settings-page.dark .ant-input-password-icon{color:var(--dark-color-text-primary)}.settings-page .ant-collapse-content-box .ant-alert{margin-block-end:12px}@media (min-width:769px){.xray-page .ant-layout-content{margin:24px 16px}}@media (max-width:768px){.xray-page .ant-tabs-nav .ant-tabs-tab{margin:0;padding:12px .5rem}.xray-page .ant-table-thead>tr>th,.xray-page .ant-table-tbody>tr>td{padding:10px 0}}.xray-page .ant-tabs-bar{margin:0}.xray-page .ant-list-item{display:block}.xray-page .ant-list-item>li{padding:10px 20px!important}.xray-page .ant-collapse-content-box .ant-alert{margin-block-end:12px}#app.login-app #login input.ant-input:-webkit-autofill{-webkit-box-shadow:0 0 0 100px #f8f8f8 inset;box-shadow:0 0 0 100px #f8f8f8 inset;transition:background-color 9999s ease-in-out 0s,color 9999s ease-in-out 0s;background-clip:text}#app.login-app #login .ant-input-affix-wrapper:hover .ant-input:-webkit-autofill:not(.ant-input-disabled),#app.login-app #login input.ant-input:-webkit-autofill:hover,#app.login-app #login input.ant-input:-webkit-autofill:focus{-webkit-box-shadow:0 0 0 100px #e8f4f2 inset;box-shadow:0 0 0 100px #e8f4f2 inset}#app.login-app.dark #login .ant-input-affix-wrapper:hover .ant-input:-webkit-autofill:not(.ant-input-disabled),#app.login-app.dark #login input.ant-input:-webkit-autofill{-webkit-text-fill-color:var(--dark-color-text-primary);caret-color:var(--dark-color-text-primary);-webkit-box-shadow:0 0 0 1000px var(--dark-color-surface-200) inset;box-shadow:0 0 0 1000px var(--dark-color-surface-200) inset;transition:background-color 9999s ease-in-out 0s,color 9999s ease-in-out 0s}#app.login-app.dark #login .ant-input-affix-wrapper:hover .ant-input:-webkit-autofill:not(.ant-input-disabled),#app.login-app.dark #login input.ant-input:-webkit-autofill:hover,#app.login-app.dark #login input.ant-input:-webkit-autofill:focus{border-color:var(--dark-color-surface-300)}.dark .ant-descriptions-bordered .ant-descriptions-item-label{background-color:var(--dark-color-background)}.dark .ant-descriptions-bordered .ant-descriptions-view,.dark .ant-descriptions-bordered .ant-descriptions-row,.dark .ant-descriptions-bordered .ant-descriptions-item-label,.dark .ant-list-bordered{border-color:var(--dark-color-surface-400)}.dark .ant-descriptions-bordered .ant-descriptions-item-label,.dark .ant-descriptions-bordered .ant-descriptions-item-content{color:var(--dark-color-text-primary)}.dark .ant-dropdown-menu{background-color:var(--dark-color-surface-200)}.dark .ant-dropdown-menu .ant-dropdown-menu-item{color:hsl(0 0% 100% / .65)}.dark .ant-dropdown-menu .ant-dropdown-menu-item:hover{background-color:var(--dark-color-surface-600)}.subscription-page .ant-list.ant-list-split.ant-list-bordered{overflow:hidden}.subscription-page .ant-list.ant-list-split.ant-list-bordered .ant-list-item{overflow-x:auto}.subscription-page .ant-btn.ant-btn-primary.ant-btn-lg.ant-dropdown-trigger{border-radius:4rem;padding:0 20px}.subscription-page .subscription-card{margin:2rem 0}.mb-10{margin-bottom:10px}.mb-12{margin-bottom:12px}.mt-5{margin-top:5px}.mr-8{margin-right:8px}.ml-10{margin-left:10px}.mr-05{margin-right:.5rem}.fs-1rem{font-size:1rem}.w-100{width:100%}.w-70{width:70px}.w-95{width:95px}.text-center{text-align:center}.cursor-pointer{cursor:pointer}.float-right{float:right}.va-middle{vertical-align:middle}.d-flex{display:flex}.justify-end{justify-content:flex-end}.max-w-400{max-width:400px;display:inline-block}.ant-space.jc-center{justify-content:center}.min-h-0{min-height:0}.min-h-100vh{min-height:100vh}.h-100{height:100%}.h-50px{height:50px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-hidden{overflow-x:hidden}.overflow-y-hidden{overflow-y:hidden}.overflow-y-auto{overflow-y:auto}.overflow-x-auto{overflow-x:auto}.mt-1rem{margin-top:1rem}.my-3rem{margin-top:3rem;margin-bottom:3rem}.nodes-page .ant-table .ant-table-tbody>tr:last-child>td:first-child{border-bottom-left-radius:1rem}.nodes-page .ant-table .ant-table-tbody>tr:last-child>td:last-child{border-bottom-right-radius:1rem}@media (min-width:769px){.nodes-page .ant-layout-content{margin:24px 16px}} diff --git a/web/assets/js/model/dbinbound.js b/web/assets/js/model/dbinbound.js index befc618e..195eb95a 100644 --- a/web/assets/js/model/dbinbound.js +++ b/web/assets/js/model/dbinbound.js @@ -20,11 +20,48 @@ class DBInbound { this.streamSettings = ""; this.tag = ""; this.sniffing = ""; - this.clientStats = "" + this.clientStats = ""; + this.nodeId = null; // Node ID for multi-node mode - DEPRECATED: kept only for backward compatibility, use nodeIds instead + this.nodeIds = []; // Node IDs array for multi-node mode - use this for multi-node support if (data == null) { return; } ObjectUtil.cloneProps(this, data); + // Ensure nodeIds is always an array (even if empty) + // Priority: use nodeIds if available, otherwise convert from deprecated nodeId + // First check if nodeIds exists and is an array (even if empty) + // Handle nodeIds from API response - it should be an array + if (this.nodeIds !== null && this.nodeIds !== undefined) { + if (Array.isArray(this.nodeIds)) { + // nodeIds is already an array - ensure all values are numbers + if (this.nodeIds.length > 0) { + this.nodeIds = this.nodeIds.map(id => { + // Convert string to number if needed + const numId = typeof id === 'string' ? parseInt(id, 10) : id; + return numId; + }).filter(id => !isNaN(id) && id > 0); + } else { + // Empty array is valid + this.nodeIds = []; + } + } else { + // nodeIds exists but is not an array - try to convert + // This shouldn't happen if API returns correct format, but handle it anyway + const nodeId = typeof this.nodeIds === 'string' ? parseInt(this.nodeIds, 10) : this.nodeIds; + this.nodeIds = !isNaN(nodeId) && nodeId > 0 ? [nodeId] : []; + } + } else if (this.nodeId !== null && this.nodeId !== undefined) { + // Convert deprecated nodeId to nodeIds array (backward compatibility) + const nodeId = typeof this.nodeId === 'string' ? parseInt(this.nodeId, 10) : this.nodeId; + this.nodeIds = !isNaN(nodeId) && nodeId > 0 ? [nodeId] : []; + } else { + // No nodes assigned - ensure empty array + this.nodeIds = []; + } + // Ensure nodeIds is never null or undefined - always an array + if (!Array.isArray(this.nodeIds)) { + this.nodeIds = []; + } } get totalGB() { @@ -116,6 +153,13 @@ class DBInbound { sniffing: sniffing, clientStats: this.clientStats, }; + // Include nodeIds if available (for multi-node mode) + if (this.nodeIds && Array.isArray(this.nodeIds) && this.nodeIds.length > 0) { + config.nodeIds = this.nodeIds; + } else if (this.nodeId !== null && this.nodeId !== undefined) { + // Backward compatibility: convert single nodeId to nodeIds array + config.nodeIds = [this.nodeId]; + } return Inbound.fromJson(config); } diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index 8d4b6819..9aa05ed3 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -1075,6 +1075,8 @@ class Inbound extends XrayCommonClass { this.tag = tag; this.sniffing = sniffing; this.clientStats = clientStats; + this.nodeIds = []; // Node IDs array for multi-node mode + this.nodeId = null; // Backward compatibility } getClientStats() { return this.clientStats; @@ -1638,10 +1640,107 @@ class Inbound extends XrayCommonClass { } } + // Extract node host from node address (e.g., "http://192.168.1.100:8080" -> "192.168.1.100") + extractNodeHost(nodeAddress) { + if (!nodeAddress) return ''; + // Remove protocol prefix + let address = nodeAddress.replace(/^https?:\/\//, ''); + // Extract host (remove port if present) + const parts = address.split(':'); + return parts[0] || address; + } + + // Get node addresses from nodeIds - returns array of all node addresses + getNodeAddresses() { + // Check if we have nodeIds and availableNodes + if (!this.nodeIds || !Array.isArray(this.nodeIds) || this.nodeIds.length === 0) { + return []; + } + + // Try to get availableNodes from global app object + let availableNodes = null; + if (typeof app !== 'undefined' && app.availableNodes) { + availableNodes = app.availableNodes; + } else if (typeof window !== 'undefined' && window.app && window.app.availableNodes) { + availableNodes = window.app.availableNodes; + } + + if (!availableNodes || availableNodes.length === 0) { + return []; + } + + // Get addresses for all node IDs + const addresses = []; + for (const nodeId of this.nodeIds) { + const node = availableNodes.find(n => n.id === nodeId); + if (node && node.address) { + const host = this.extractNodeHost(node.address); + if (host) { + addresses.push(host); + } + } + } + + return addresses; + } + + // Get node addresses with their IDs - returns array of {address, nodeId} + getNodeAddressesWithIds() { + // Check if we have nodeIds and availableNodes + if (!this.nodeIds || !Array.isArray(this.nodeIds) || this.nodeIds.length === 0) { + return []; + } + + // Try to get availableNodes from global app object + let availableNodes = null; + if (typeof app !== 'undefined' && app.availableNodes) { + availableNodes = app.availableNodes; + } else if (typeof window !== 'undefined' && window.app && window.app.availableNodes) { + availableNodes = window.app.availableNodes; + } + + if (!availableNodes || availableNodes.length === 0) { + return []; + } + + // Get addresses with node IDs for all node IDs + const result = []; + for (const nodeId of this.nodeIds) { + const node = availableNodes.find(n => n.id === nodeId); + if (node && node.address) { + const host = this.extractNodeHost(node.address); + if (host) { + result.push({ address: host, nodeId: nodeId }); + } + } + } + + return result; + } + + // Get first node address (for backward compatibility) + getNodeAddress() { + const addresses = this.getNodeAddresses(); + return addresses.length > 0 ? addresses[0] : null; + } + genAllLinks(remark = '', remarkModel = '-ieo', client) { let result = []; let email = client ? client.email : ''; - let addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname; + + // Get all node addresses with their IDs + const nodeAddressesWithIds = this.getNodeAddressesWithIds(); + + // Determine addresses to use + let addressesWithIds = []; + if (nodeAddressesWithIds.length > 0) { + addressesWithIds = nodeAddressesWithIds; + } else if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") { + addressesWithIds = [{ address: this.listen, nodeId: null }]; + } else { + addressesWithIds = [{ address: location.hostname, nodeId: null }]; + } + let port = this.port; const separationChar = remarkModel.charAt(0); const orderChars = remarkModel.slice(1); @@ -1650,19 +1749,26 @@ class Inbound extends XrayCommonClass { 'e': email, 'o': '', }; + if (ObjectUtil.isArrEmpty(this.stream.externalProxy)) { - let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar); - result.push({ - remark: r, - link: this.genLink(addr, port, 'same', r, client) + // Generate links for each node address + addressesWithIds.forEach((addrInfo) => { + let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar); + result.push({ + remark: r, + link: this.genLink(addrInfo.address, port, 'same', r, client), + nodeId: addrInfo.nodeId + }); }); } else { + // External proxy takes precedence this.stream.externalProxy.forEach((ep) => { orders['o'] = ep.remark; let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar); result.push({ remark: r, - link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client) + link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client), + nodeId: null }); }); } @@ -1670,7 +1776,18 @@ class Inbound extends XrayCommonClass { } genInboundLinks(remark = '', remarkModel = '-ieo') { - let addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname; + // Get all node addresses + const nodeAddresses = this.getNodeAddresses(); + + // Determine addresses to use + let addresses = []; + if (nodeAddresses.length > 0) { + addresses = nodeAddresses; + } else if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") { + addresses = [this.listen]; + } else { + addresses = [location.hostname]; + } if (this.clients) { let links = []; this.clients.forEach((client) => { @@ -1680,11 +1797,20 @@ class Inbound extends XrayCommonClass { }); return links.join('\r\n'); } else { - if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) return this.genSSLink(addr, this.port, 'same', remark); + if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) { + // Generate links for each node address + let links = []; + addresses.forEach((addr) => { + links.push(this.genSSLink(addr, this.port, 'same', remark)); + }); + return links.join('\r\n'); + } if (this.protocol == Protocols.WIREGUARD) { let links = []; - this.settings.peers.forEach((p, index) => { - links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index)); + addresses.forEach((addr) => { + this.settings.peers.forEach((p, index) => { + links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index)); + }); }); return links.join('\r\n'); } @@ -1693,7 +1819,7 @@ class Inbound extends XrayCommonClass { } static fromJson(json = {}) { - return new Inbound( + const inbound = new Inbound( json.port, json.listen, json.protocol, @@ -1702,7 +1828,14 @@ class Inbound extends XrayCommonClass { json.tag, Sniffing.fromJson(json.sniffing), json.clientStats - ) + ); + // Restore nodeIds if present + if (json.nodeIds && Array.isArray(json.nodeIds)) { + inbound.nodeIds = json.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id); + } else if (json.nodeId !== null && json.nodeId !== undefined) { + inbound.nodeIds = [typeof json.nodeId === 'string' ? parseInt(json.nodeId, 10) : json.nodeId]; + } + return inbound; } toJson() { @@ -1710,7 +1843,7 @@ class Inbound extends XrayCommonClass { if (this.canEnableStream() || this.stream?.sockopt) { streamSettings = this.stream.toJson(); } - return { + const result = { port: this.port, listen: this.listen, protocol: this.protocol, @@ -1720,6 +1853,11 @@ class Inbound extends XrayCommonClass { sniffing: this.sniffing.toJson(), clientStats: this.clientStats }; + // Include nodeIds if present + if (this.nodeIds && Array.isArray(this.nodeIds) && this.nodeIds.length > 0) { + result.nodeIds = this.nodeIds; + } + return result; } } diff --git a/web/assets/js/model/node.js b/web/assets/js/model/node.js new file mode 100644 index 00000000..41722157 --- /dev/null +++ b/web/assets/js/model/node.js @@ -0,0 +1,82 @@ +class Node { + constructor(data) { + this.id = 0; + this.name = ""; + this.address = ""; + this.apiKey = ""; + this.status = "unknown"; + this.lastCheck = 0; + this.createdAt = 0; + this.updatedAt = 0; + + if (data == null) { + return; + } + ObjectUtil.cloneProps(this, data); + } + + get isOnline() { + return this.status === "online"; + } + + get isOffline() { + return this.status === "offline"; + } + + get isError() { + return this.status === "error"; + } + + get isUnknown() { + return this.status === "unknown" || !this.status; + } + + get statusColor() { + switch (this.status) { + case 'online': return 'green'; + case 'offline': return 'red'; + case 'error': return 'red'; + default: return 'default'; + } + } + + get statusIcon() { + switch (this.status) { + case 'online': return 'check-circle'; + case 'offline': return 'close-circle'; + case 'error': return 'exclamation-circle'; + default: return 'question-circle'; + } + } + + get formattedLastCheck() { + if (!this.lastCheck || this.lastCheck === 0) { + return '-'; + } + const date = new Date(this.lastCheck * 1000); + const now = new Date(); + const diff = Math.floor((now - date) / 1000); + + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; + } + + toJson() { + return { + id: this.id, + name: this.name, + address: this.address, + apiKey: this.apiKey, + status: this.status, + lastCheck: this.lastCheck, + createdAt: this.createdAt, + updatedAt: this.updatedAt + }; + } + + static fromJson(json) { + return new Node(json); + } +} diff --git a/web/assets/js/model/setting.js b/web/assets/js/model/setting.js index 53ffae1a..3446832d 100644 --- a/web/assets/js/model/setting.js +++ b/web/assets/js/model/setting.js @@ -72,10 +72,24 @@ class AllSetting { this.ldapDefaultExpiryDays = 0; this.ldapDefaultLimitIP = 0; + // Multi-node mode settings + this.multiNodeMode = false; // Multi-node mode setting + if (data == null) { return } ObjectUtil.cloneProps(this, data); + + // Ensure multiNodeMode is boolean (handle string "true"/"false" from backend) + if (this.multiNodeMode !== undefined && this.multiNodeMode !== null) { + if (typeof this.multiNodeMode === 'string') { + this.multiNodeMode = this.multiNodeMode === 'true' || this.multiNodeMode === '1'; + } else { + this.multiNodeMode = Boolean(this.multiNodeMode); + } + } else { + this.multiNodeMode = false; + } } equals(other) { diff --git a/web/controller/inbound.go b/web/controller/inbound.go index 8317de31..a1b8be40 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -1,11 +1,14 @@ package controller import ( + "bytes" "encoding/json" "fmt" + "io" "strconv" "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/session" "github.com/mhsanaei/3x-ui/v2/web/websocket" @@ -103,12 +106,61 @@ func (a *InboundController) getClientTrafficsById(c *gin.Context) { // addInbound creates a new inbound configuration. func (a *InboundController) addInbound(c *gin.Context) { + // Try to get nodeIds from JSON body first (if Content-Type is application/json) + // This must be done BEFORE ShouldBind, which reads the body + var nodeIdsFromJSON []int + var nodeIdFromJSON *int + var hasNodeIdsInJSON, hasNodeIdInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract nodeIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract nodeIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for nodeIds array + if nodeIdsVal, ok := jsonData["nodeIds"]; ok { + hasNodeIdsInJSON = true + if nodeIdsArray, ok := nodeIdsVal.([]interface{}); ok { + for _, val := range nodeIdsArray { + if num, ok := val.(float64); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, num) + } + } + } else if num, ok := nodeIdsVal.(float64); ok { + // Single number instead of array + nodeIdsFromJSON = append(nodeIdsFromJSON, int(num)) + } else if num, ok := nodeIdsVal.(int); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, num) + } + } + // Check for nodeId (backward compatibility) + if nodeIdVal, ok := jsonData["nodeId"]; ok { + hasNodeIdInJSON = true + if num, ok := nodeIdVal.(float64); ok { + nodeId := int(num) + nodeIdFromJSON = &nodeId + } else if num, ok := nodeIdVal.(int); ok { + nodeIdFromJSON = &num + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + inbound := &model.Inbound{} err := c.ShouldBind(inbound) if err != nil { + logger.Errorf("Failed to bind inbound data: %v", err) jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err) return } + user := session.GetLoginUser(c) inbound.UserId = user.Id if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { @@ -119,9 +171,65 @@ func (a *InboundController) addInbound(c *gin.Context) { inbound, needRestart, err := a.inboundService.AddInbound(inbound) if err != nil { + logger.Errorf("Failed to add inbound: %v", err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return } + + // Handle node assignment in multi-node mode + nodeService := service.NodeService{} + + // Get nodeIds from form (for form-encoded requests) + nodeIdsStr := c.PostFormArray("nodeIds") + logger.Debugf("Received nodeIds from form: %v", nodeIdsStr) + + // Check if nodeIds array was provided (even if empty) + nodeIdStr := c.PostForm("nodeId") + + // Determine which source to use: JSON takes precedence over form data + useJSON := hasNodeIdsInJSON || hasNodeIdInJSON + useForm := (len(nodeIdsStr) > 0 || nodeIdStr != "") && !useJSON + + if useJSON || useForm { + var nodeIds []int + var nodeId *int + + if useJSON { + // Use data from JSON + nodeIds = nodeIdsFromJSON + nodeId = nodeIdFromJSON + } else { + // Parse nodeIds array from form + for _, idStr := range nodeIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + nodeIds = append(nodeIds, id) + } + } + } + // Parse single nodeId from form + if nodeIdStr != "" && nodeIdStr != "null" { + if parsedId, err := strconv.Atoi(nodeIdStr); err == nil && parsedId > 0 { + nodeId = &parsedId + } + } + } + + if len(nodeIds) > 0 { + // Assign to multiple nodes + if err := nodeService.AssignInboundToNodes(inbound.Id, nodeIds); err != nil { + logger.Errorf("Failed to assign inbound %d to nodes %v: %v", inbound.Id, nodeIds, err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + } else if nodeId != nil && *nodeId > 0 { + // Backward compatibility: single nodeId + if err := nodeService.AssignInboundToNode(inbound.Id, *nodeId); err != nil { + logger.Warningf("Failed to assign inbound %d to node %d: %v", inbound.Id, *nodeId, err) + } + } + } + jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil) if needRestart { a.xrayService.SetToNeedRestart() @@ -160,19 +268,151 @@ func (a *InboundController) updateInbound(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) return } + + // Try to get nodeIds from JSON body first (if Content-Type is application/json) + var nodeIdsFromJSON []int + var nodeIdFromJSON *int + var hasNodeIdsInJSON, hasNodeIdInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract nodeIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract nodeIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for nodeIds array + if nodeIdsVal, ok := jsonData["nodeIds"]; ok { + hasNodeIdsInJSON = true + if nodeIdsArray, ok := nodeIdsVal.([]interface{}); ok { + for _, val := range nodeIdsArray { + if num, ok := val.(float64); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, num) + } + } + } else if num, ok := nodeIdsVal.(float64); ok { + // Single number instead of array + nodeIdsFromJSON = append(nodeIdsFromJSON, int(num)) + } else if num, ok := nodeIdsVal.(int); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, num) + } + } + // Check for nodeId (backward compatibility) + if nodeIdVal, ok := jsonData["nodeId"]; ok { + hasNodeIdInJSON = true + if num, ok := nodeIdVal.(float64); ok { + nodeId := int(num) + nodeIdFromJSON = &nodeId + } else if num, ok := nodeIdVal.(int); ok { + nodeIdFromJSON = &num + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + // Get nodeIds from form (for form-encoded requests) + nodeIdsStr := c.PostFormArray("nodeIds") + logger.Debugf("Received nodeIds from form: %v (count: %d)", nodeIdsStr, len(nodeIdsStr)) + + // Check if nodeIds array was provided + nodeIdStr := c.PostForm("nodeId") + logger.Debugf("Received nodeId from form: %s", nodeIdStr) + + // Check if nodeIds or nodeId was explicitly provided in the form + _, hasNodeIds := c.GetPostForm("nodeIds") + _, hasNodeId := c.GetPostForm("nodeId") + logger.Debugf("Form has nodeIds: %v, has nodeId: %v", hasNodeIds, hasNodeId) + logger.Debugf("JSON has nodeIds: %v (values: %v), has nodeId: %v (value: %v)", hasNodeIdsInJSON, nodeIdsFromJSON, hasNodeIdInJSON, nodeIdFromJSON) + inbound := &model.Inbound{ Id: id, } + // Bind inbound data (nodeIds will be ignored since we handle it separately) err = c.ShouldBind(inbound) if err != nil { + logger.Errorf("Failed to bind inbound data: %v", err) jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) return } inbound, needRestart, err := a.inboundService.UpdateInbound(inbound) if err != nil { + logger.Errorf("Failed to update inbound: %v", err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return } + + // Handle node assignment in multi-node mode + nodeService := service.NodeService{} + + // Determine which source to use: JSON takes precedence over form data + useJSON := hasNodeIdsInJSON || hasNodeIdInJSON + useForm := (hasNodeIds || hasNodeId) && !useJSON + + if useJSON || useForm { + var nodeIds []int + var nodeId *int + var hasNodeIdsFlag bool + + if useJSON { + // Use data from JSON + nodeIds = nodeIdsFromJSON + nodeId = nodeIdFromJSON + hasNodeIdsFlag = hasNodeIdsInJSON + } else { + // Use data from form + hasNodeIdsFlag = hasNodeIds + // Parse nodeIds array from form + for _, idStr := range nodeIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + nodeIds = append(nodeIds, id) + } else { + logger.Warningf("Invalid nodeId in array: %s (error: %v)", idStr, err) + } + } + } + // Parse single nodeId from form + if nodeIdStr != "" && nodeIdStr != "null" { + if parsedId, err := strconv.Atoi(nodeIdStr); err == nil && parsedId > 0 { + nodeId = &parsedId + } + } + } + + logger.Debugf("Parsed nodeIds: %v, nodeId: %v", nodeIds, nodeId) + + if len(nodeIds) > 0 { + // Assign to multiple nodes + if err := nodeService.AssignInboundToNodes(inbound.Id, nodeIds); err != nil { + logger.Errorf("Failed to assign inbound %d to nodes %v: %v", inbound.Id, nodeIds, err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + logger.Debugf("Successfully assigned inbound %d to nodes %v", inbound.Id, nodeIds) + } else if nodeId != nil && *nodeId > 0 { + // Backward compatibility: single nodeId + if err := nodeService.AssignInboundToNode(inbound.Id, *nodeId); err != nil { + logger.Errorf("Failed to assign inbound %d to node %d: %v", inbound.Id, *nodeId, err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + logger.Debugf("Successfully assigned inbound %d to node %d", inbound.Id, *nodeId) + } else if hasNodeIdsFlag { + // nodeIds was explicitly provided but is empty - unassign all + if err := nodeService.UnassignInboundFromNode(inbound.Id); err != nil { + logger.Warningf("Failed to unassign inbound %d from nodes: %v", inbound.Id, err) + } else { + logger.Debugf("Successfully unassigned inbound %d from all nodes", inbound.Id) + } + } + // If neither nodeIds nor nodeId was provided, don't change assignments + } + jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), inbound, nil) if needRestart { a.xrayService.SetToNeedRestart() @@ -367,7 +607,8 @@ func (a *InboundController) delDepletedClients(c *gin.Context) { // onlines retrieves the list of currently online clients. func (a *InboundController) onlines(c *gin.Context) { - jsonObj(c, a.inboundService.GetOnlineClients(), nil) + clients := a.inboundService.GetOnlineClients() + jsonObj(c, clients, nil) } // lastOnline retrieves the last online timestamps for clients. diff --git a/web/controller/node.go b/web/controller/node.go new file mode 100644 index 00000000..bd3ca595 --- /dev/null +++ b/web/controller/node.go @@ -0,0 +1,297 @@ +// Package controller provides HTTP handlers for node management in multi-node mode. +package controller + +import ( + "strconv" + + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/web/service" + + "github.com/gin-gonic/gin" +) + +// NodeController handles HTTP requests related to node management. +type NodeController struct { + nodeService service.NodeService +} + +// NewNodeController creates a new NodeController and sets up its routes. +func NewNodeController(g *gin.RouterGroup) *NodeController { + a := &NodeController{ + nodeService: service.NodeService{}, + } + a.initRouter(g) + return a +} + +// initRouter initializes the routes for node-related operations. +func (a *NodeController) initRouter(g *gin.RouterGroup) { + g.GET("/list", a.getNodes) + g.GET("/get/:id", a.getNode) + g.POST("/add", a.addNode) + g.POST("/update/:id", a.updateNode) + g.POST("/del/:id", a.deleteNode) + g.POST("/check/:id", a.checkNode) + g.POST("/checkAll", a.checkAllNodes) + g.POST("/reload/:id", a.reloadNode) + g.POST("/reloadAll", a.reloadAllNodes) + g.GET("/status/:id", a.getNodeStatus) +} + +// getNodes retrieves the list of all nodes. +func (a *NodeController) getNodes(c *gin.Context) { + nodes, err := a.nodeService.GetAllNodes() + if err != nil { + jsonMsg(c, "Failed to get nodes", err) + return + } + + // Enrich nodes with assigned inbounds information + type NodeWithInbounds struct { + *model.Node + Inbounds []*model.Inbound `json:"inbounds,omitempty"` + } + + result := make([]NodeWithInbounds, 0, len(nodes)) + for _, node := range nodes { + inbounds, _ := a.nodeService.GetInboundsForNode(node.Id) + result = append(result, NodeWithInbounds{ + Node: node, + Inbounds: inbounds, + }) + } + + jsonObj(c, result, nil) +} + +// getNode retrieves a specific node by its ID. +func (a *NodeController) getNode(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid node ID", err) + return + } + node, err := a.nodeService.GetNode(id) + if err != nil { + jsonMsg(c, "Failed to get node", err) + return + } + jsonObj(c, node, nil) +} + +// addNode creates a new node. +func (a *NodeController) addNode(c *gin.Context) { + node := &model.Node{} + err := c.ShouldBind(node) + if err != nil { + jsonMsg(c, "Invalid node data", err) + return + } + + // Log received data for debugging + logger.Debugf("Adding node: name=%s, address=%s, apiKey=%s", node.Name, node.Address, node.ApiKey) + + // Validate API key before saving + err = a.nodeService.ValidateApiKey(node) + if err != nil { + logger.Errorf("API key validation failed for node %s: %v", node.Address, err) + jsonMsg(c, "Invalid API key or node unreachable: "+err.Error(), err) + return + } + + // Set default status + if node.Status == "" { + node.Status = "unknown" + } + + err = a.nodeService.AddNode(node) + if err != nil { + jsonMsg(c, "Failed to add node", err) + return + } + + // Check health immediately + go a.nodeService.CheckNodeHealth(node) + + jsonMsgObj(c, "Node added successfully", node, nil) +} + +// updateNode updates an existing node. +func (a *NodeController) updateNode(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid node ID", err) + return + } + + // Get existing node first to preserve fields that are not being updated + existingNode, err := a.nodeService.GetNode(id) + if err != nil { + jsonMsg(c, "Failed to get existing node", err) + return + } + + // Create node with only provided fields + node := &model.Node{Id: id} + + // Try to parse as JSON first (for API calls) + contentType := c.GetHeader("Content-Type") + if contentType == "application/json" { + var jsonData map[string]interface{} + if err := c.ShouldBindJSON(&jsonData); err == nil { + // Only set fields that are provided in JSON + if nameVal, ok := jsonData["name"].(string); ok && nameVal != "" { + node.Name = nameVal + } + if addressVal, ok := jsonData["address"].(string); ok && addressVal != "" { + node.Address = addressVal + } + if apiKeyVal, ok := jsonData["apiKey"].(string); ok && apiKeyVal != "" { + node.ApiKey = apiKeyVal + } + } + } else { + // Parse as form data (default for web UI) + // Only extract fields that are actually provided + if name := c.PostForm("name"); name != "" { + node.Name = name + } + if address := c.PostForm("address"); address != "" { + node.Address = address + } + if apiKey := c.PostForm("apiKey"); apiKey != "" { + node.ApiKey = apiKey + } + } + + // Validate API key if it was changed + if node.ApiKey != "" && node.ApiKey != existingNode.ApiKey { + // Create a temporary node for validation + validationNode := &model.Node{ + Id: id, + Address: node.Address, + ApiKey: node.ApiKey, + } + if validationNode.Address == "" { + validationNode.Address = existingNode.Address + } + err = a.nodeService.ValidateApiKey(validationNode) + if err != nil { + jsonMsg(c, "Invalid API key or node unreachable: "+err.Error(), err) + return + } + } + + err = a.nodeService.UpdateNode(node) + if err != nil { + jsonMsg(c, "Failed to update node", err) + return + } + + jsonMsgObj(c, "Node updated successfully", node, nil) +} + +// deleteNode deletes a node by its ID. +func (a *NodeController) deleteNode(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid node ID", err) + return + } + + err = a.nodeService.DeleteNode(id) + if err != nil { + jsonMsg(c, "Failed to delete node", err) + return + } + + jsonMsg(c, "Node deleted successfully", nil) +} + +// checkNode checks the health of a specific node. +func (a *NodeController) checkNode(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid node ID", err) + return + } + + node, err := a.nodeService.GetNode(id) + if err != nil { + jsonMsg(c, "Failed to get node", err) + return + } + + err = a.nodeService.CheckNodeHealth(node) + if err != nil { + jsonMsg(c, "Node health check failed", err) + return + } + + jsonMsgObj(c, "Node health check completed", node, nil) +} + +// checkAllNodes checks the health of all nodes. +func (a *NodeController) checkAllNodes(c *gin.Context) { + a.nodeService.CheckAllNodesHealth() + jsonMsg(c, "Health check initiated for all nodes", nil) +} + +// getNodeStatus retrieves the detailed status of a node. +func (a *NodeController) getNodeStatus(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid node ID", err) + return + } + + node, err := a.nodeService.GetNode(id) + if err != nil { + jsonMsg(c, "Failed to get node", err) + return + } + + status, err := a.nodeService.GetNodeStatus(node) + if err != nil { + jsonMsg(c, "Failed to get node status", err) + return + } + + jsonObj(c, status, nil) +} + +// reloadNode reloads XRAY on a specific node. +func (a *NodeController) reloadNode(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid node ID", err) + return + } + + node, err := a.nodeService.GetNode(id) + if err != nil { + jsonMsg(c, "Failed to get node", err) + return + } + + // Use force reload to handle hung nodes + err = a.nodeService.ForceReloadNode(node) + if err != nil { + jsonMsg(c, "Failed to reload node", err) + return + } + + jsonMsg(c, "Node reloaded successfully", nil) +} + +// reloadAllNodes reloads XRAY on all nodes. +func (a *NodeController) reloadAllNodes(c *gin.Context) { + err := a.nodeService.ReloadAllNodes() + if err != nil { + jsonMsg(c, "Failed to reload some nodes", err) + return + } + + jsonMsg(c, "All nodes reloaded successfully", nil) +} diff --git a/web/controller/xui.go b/web/controller/xui.go index 51502900..f11a0422 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -10,6 +10,7 @@ type XUIController struct { settingController *SettingController xraySettingController *XraySettingController + nodeController *NodeController } // NewXUIController creates a new XUIController and initializes its routes. @@ -28,9 +29,11 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { g.GET("/inbounds", a.inbounds) g.GET("/settings", a.settings) g.GET("/xray", a.xraySettings) + g.GET("/nodes", a.nodes) a.settingController = NewSettingController(g) a.xraySettingController = NewXraySettingController(g) + a.nodeController = NewNodeController(g.Group("/node")) } // index renders the main panel index page. @@ -52,3 +55,8 @@ func (a *XUIController) settings(c *gin.Context) { func (a *XUIController) xraySettings(c *gin.Context) { html(c, "xray.html", "pages.xray.title", nil) } + +// nodes renders the nodes management page (multi-node mode). +func (a *XUIController) nodes(c *gin.Context) { + html(c, "nodes.html", "pages.nodes.title", nil) +} diff --git a/web/entity/entity.go b/web/entity/entity.go index 42e2df85..030da972 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -98,6 +98,9 @@ type AllSetting struct { LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"` LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"` LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"` + + // Multi-node mode setting + MultiNodeMode bool `json:"multiNodeMode" form:"multiNodeMode"` // Enable multi-node architecture mode // JSON subscription routing rules } diff --git a/web/html/component/aSidebar.html b/web/html/component/aSidebar.html index b69c8f3f..8f0b1ee5 100644 --- a/web/html/component/aSidebar.html +++ b/web/html/component/aSidebar.html @@ -43,7 +43,29 @@ Vue.component('a-sidebar', { data() { return { - tabs: [ + tabs: [], + activeTab: [ + '{{ .request_uri }}' + ], + visible: false, + collapsed: JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)), + multiNodeMode: false + } + }, + methods: { + async loadMultiNodeMode() { + try { + const msg = await HttpUtil.post("/panel/setting/all"); + if (msg && msg.success && msg.obj) { + this.multiNodeMode = msg.obj.multiNodeMode || false; + this.updateTabs(); + } + } catch (e) { + console.warn("Failed to load multi-node mode:", e); + } + }, + updateTabs() { + this.tabs = [ { key: '{{ .base_path }}panel/', icon: 'dashboard', @@ -63,21 +85,24 @@ key: '{{ .base_path }}panel/xray', icon: 'tool', title: '{{ i18n "menu.xray"}}' - }, - { - key: '{{ .base_path }}logout/', - icon: 'logout', - title: '{{ i18n "menu.logout"}}' - }, - ], - activeTab: [ - '{{ .request_uri }}' - ], - visible: false, - collapsed: JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)), - } - }, - methods: { + } + ]; + + // Add Nodes menu item if multi-node mode is enabled + if (this.multiNodeMode) { + this.tabs.splice(3, 0, { + key: '{{ .base_path }}panel/nodes', + icon: 'cluster', + title: '{{ i18n "menu.nodes"}}' + }); + } + + this.tabs.push({ + key: '{{ .base_path }}logout/', + icon: 'logout', + title: '{{ i18n "menu.logout"}}' + }); + }, openLink(key) { return key.startsWith('http') ? window.open(key) : @@ -97,6 +122,14 @@ } } }, + mounted() { + this.updateTabs(); + this.loadMultiNodeMode(); + // Watch for multi-node mode changes + setInterval(() => { + this.loadMultiNodeMode(); + }, 5000); + }, template: `{{template "component/sidebar/content"}}`, }); diff --git a/web/html/form/inbound.html b/web/html/form/inbound.html index fdd381b0..b7b34cd3 100644 --- a/web/html/form/inbound.html +++ b/web/html/form/inbound.html @@ -31,6 +31,26 @@ + + + + + [[ node.name ]] [[ node.status ]] + + +
+ No nodes available. Please add nodes first. +
+
+ @@ -706,6 +719,8 @@ el: '#app', mixins: [MediaQueryMixin], data: { + availableNodes: [], + multiNodeMode: false, themeSwitcher, persianDatepicker, loadingStates: { @@ -746,6 +761,44 @@ loading(spinning = true) { this.loadingStates.spinning = spinning; }, + async loadMultiNodeMode() { + try { + const msg = await HttpUtil.post("/panel/setting/all"); + if (msg && msg.success && msg.obj) { + this.multiNodeMode = Boolean(msg.obj.multiNodeMode) || false; + // Store in allSetting for modal access + if (!this.allSetting) { + this.allSetting = {}; + } + this.allSetting.multiNodeMode = this.multiNodeMode; + // Load available nodes if in multi-node mode + if (this.multiNodeMode) { + await this.loadAvailableNodes(); + } + } + } catch (e) { + console.warn("Failed to load multi-node mode:", e); + } + }, + async loadAvailableNodes() { + try { + const msg = await HttpUtil.get("/panel/node/list"); + if (msg && msg.success && msg.obj) { + this.availableNodes = msg.obj.map(node => ({ + id: node.id, + name: node.name, + address: node.address, + status: node.status || 'unknown' + })); + } + } catch (e) { + console.warn("Failed to load available nodes:", e); + } + }, + getNodeName(nodeId) { + const node = this.availableNodes.find(n => n.id === nodeId); + return node ? node.name : null; + }, async getDBInbounds() { this.refreshing = true; const msg = await HttpUtil.get('/panel/api/inbounds/list'); @@ -804,6 +857,11 @@ this.clientCount.splice(0); for (const inbound of dbInbounds) { const dbInbound = new DBInbound(inbound); + // Ensure nodeIds are properly set after creating DBInbound + // The constructor should handle this, but double-check + if (!Array.isArray(dbInbound.nodeIds)) { + dbInbound.nodeIds = []; + } to_inbound = dbInbound.toInbound() this.inbounds.push(to_inbound); this.dbInbounds.push(dbInbound); @@ -1041,6 +1099,20 @@ openEditInbound(dbInboundId) { dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); const inbound = dbInbound.toInbound(); + // Set nodeIds from dbInbound if available - ensure they are numbers + // This is critical: dbInbound is the source of truth for nodeIds + let nodeIdsToSet = []; + if (dbInbound.nodeIds && Array.isArray(dbInbound.nodeIds) && dbInbound.nodeIds.length > 0) { + nodeIdsToSet = dbInbound.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0); + } else if (dbInbound.nodeId !== null && dbInbound.nodeId !== undefined) { + // Backward compatibility: single nodeId + const nodeId = typeof dbInbound.nodeId === 'string' ? parseInt(dbInbound.nodeId, 10) : dbInbound.nodeId; + if (!isNaN(nodeId) && nodeId > 0) { + nodeIdsToSet = [nodeId]; + } + } + // Ensure nodeIds are set on inbound object before passing to modal + inbound.nodeIds = nodeIdsToSet; inModal.show({ title: '{{ i18n "pages.inbounds.modifyInbound"}}', okText: '{{ i18n "update"}}', @@ -1075,6 +1147,14 @@ data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2); } data.sniffing = inbound.sniffing.toString(); + + // Add nodeIds if multi-node mode is enabled + if (this.multiNodeMode && inbound.nodeIds && Array.isArray(inbound.nodeIds) && inbound.nodeIds.length > 0) { + data.nodeIds = inbound.nodeIds; + } else if (this.multiNodeMode && inbound.nodeId) { + // Backward compatibility: single nodeId + data.nodeId = inbound.nodeId; + } await this.submit('/panel/api/inbounds/add', data, inModal); }, @@ -1100,6 +1180,21 @@ data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2); } data.sniffing = inbound.sniffing.toString(); + + // Add nodeIds if multi-node mode is enabled + if (this.multiNodeMode) { + if (inbound.nodeIds && Array.isArray(inbound.nodeIds) && inbound.nodeIds.length > 0) { + // Ensure all values are numbers + data.nodeIds = inbound.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0); + } else if (inbound.nodeId !== null && inbound.nodeId !== undefined) { + // Backward compatibility: single nodeId + const nodeId = typeof inbound.nodeId === 'string' ? parseInt(inbound.nodeId, 10) : inbound.nodeId; + if (!isNaN(nodeId) && nodeId > 0) { + data.nodeId = nodeId; + } + } + // If no nodes selected, don't send nodeIds field at all - server will handle unassignment + } await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, data, inModal); }, @@ -1252,6 +1347,10 @@ }, checkFallback(dbInbound) { newDbInbound = new DBInbound(dbInbound); + // Ensure nodeIds are preserved when creating new DBInbound + if (dbInbound.nodeIds && Array.isArray(dbInbound.nodeIds)) { + newDbInbound.nodeIds = dbInbound.nodeIds; + } if (dbInbound.listen.startsWith("@")) { rootInbound = this.inbounds.find((i) => i.isTcp && @@ -1312,7 +1411,10 @@ async submit(url, data, modal) { const msg = await HttpUtil.postWithModal(url, data, modal); if (msg.success) { + // Force reload inbounds to get updated nodeIds from server await this.getDBInbounds(); + // Force Vue to update the view + this.$forceUpdate(); } }, getInboundClients(dbInbound) { @@ -1581,7 +1683,8 @@ this.searchInbounds(newVal); }, 500) }, - mounted() { + async mounted() { + await this.loadMultiNodeMode(); if (window.location.protocol !== "https:") { this.showAlert = true; } diff --git a/web/html/index.html b/web/html/index.html index bbbbb708..65d5ab92 100644 --- a/web/html/index.html +++ b/web/html/index.html @@ -78,6 +78,19 @@ + + + + + +
+ {{ i18n "pages.index.nodesAvailability" }}: [[ status.nodes.online ]] / [[ status.nodes.total ]] +
+
+
+
+
- + + + + + + + + + + + +