From 9263402370190b9327fa68d0923e34fa1c8b2833 Mon Sep 17 00:00:00 2001 From: Konstantin Pichugin Date: Mon, 12 Jan 2026 05:01:31 +0300 Subject: [PATCH] edit registration steps nodes (auto setup api-key), edit loggers --- database/model/model.go | 7 +- logger/logger.go | 22 ++ node/api/server.go | 140 ++++++++++- node/config/config.go | 156 ++++++++++++ node/docker-compose.yml | 9 +- node/logs/pusher.go | 346 +++++++++++++++++++++++++++ node/main.go | 72 +++++- node/xray/manager.go | 70 ++++++ web/controller/api.go | 158 +++++++++++- web/controller/node.go | 258 +++++++++++++++++++- web/controller/server.go | 3 +- web/html/index.html | 83 ++++++- web/html/modals/node_modal.html | 141 ++++++++--- web/html/nodes.html | 169 ++++++++++++- web/job/check_node_health_job.go | 42 +++- web/job/collect_node_logs_job.go | 283 ++++++++++++++++++++++ web/service/node.go | 271 +++++++++++++++++++-- web/service/server.go | 69 +++++- web/service/xray.go | 6 +- web/translation/translate.en_US.toml | 12 + web/translation/translate.ru_RU.toml | 12 + web/web.go | 3 + web/websocket/hub.go | 1 + web/websocket/notifier.go | 8 + 24 files changed, 2244 insertions(+), 97 deletions(-) create mode 100644 node/config/config.go create mode 100644 node/logs/pusher.go create mode 100644 web/job/collect_node_logs_job.go diff --git a/database/model/model.go b/database/model/model.go index 4249d431..92f79aa1 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -167,9 +167,10 @@ type Node struct { 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" or "https://...") 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 - UseTLS bool `json:"useTls" form:"useTls" gorm:"column:use_tls;default:false"` // Whether to use TLS/HTTPS for API calls + Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown + LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp + ResponseTime int64 `json:"responseTime" gorm:"default:0"` // Response time in milliseconds (0 = not measured or error) + UseTLS bool `json:"useTls" form:"useTls" gorm:"column:use_tls;default:false"` // Whether to use TLS/HTTPS for API calls CertPath string `json:"certPath" form:"certPath" gorm:"column:cert_path"` // Path to certificate file (optional, for custom CA) KeyPath string `json:"keyPath" form:"keyPath" gorm:"column:key_path"` // Path to private key file (optional, for custom CA) InsecureTLS bool `json:"insecureTls" form:"insecureTls" gorm:"column:insecure_tls;default:false"` // Skip certificate verification (not recommended) diff --git a/logger/logger.go b/logger/logger.go index 4ab3b4cf..72a8d3ac 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "time" "github.com/mhsanaei/3x-ui/v2/config" @@ -209,6 +210,27 @@ func addToBuffer(level string, newLog string) { level: logLevel, log: newLog, }) + + // If running on node, push log to panel in real-time + // Check if we're in node mode by checking for NODE_API_KEY environment variable + if os.Getenv("NODE_API_KEY") != "" { + // Format log line as "timestamp level - message" for panel + logLine := fmt.Sprintf("%s %s - %s", t.Format(timeFormat), strings.ToUpper(level), newLog) + // Use build tag or lazy initialization to avoid circular dependency + // For now, we'll use a simple check - if node/logs package is available + pushLogToPanel(logLine) + } +} + +// pushLogToPanel pushes a log line to the panel (called from node mode only). +// This function will be implemented in node package to avoid circular dependency. +var pushLogToPanel = func(logLine string) { + // Default: no-op, will be overridden by node package if available +} + +// SetLogPusher sets the function to push logs to panel (called from node package). +func SetLogPusher(pusher func(string)) { + pushLogToPanel = pusher } // GetLogs retrieves up to c log entries from the buffer that are at or below the specified level. diff --git a/node/api/server.go b/node/api/server.go index 11fa93c4..3cd189ea 100644 --- a/node/api/server.go +++ b/node/api/server.go @@ -6,9 +6,12 @@ import ( "fmt" "io" "net/http" + "strconv" "time" "github.com/mhsanaei/3x-ui/v2/logger" + nodeConfig "github.com/mhsanaei/3x-ui/v2/node/config" + nodeLogs "github.com/mhsanaei/3x-ui/v2/node/logs" "github.com/mhsanaei/3x-ui/v2/node/xray" "github.com/gin-gonic/gin" ) @@ -40,6 +43,9 @@ func (s *Server) Start() error { // Health check endpoint (no auth required) router.GET("/health", s.health) + // Registration endpoint (no auth required, used for initial setup) + router.POST("/api/v1/register", s.register) + // API endpoints (require auth) api := router.Group("/api/v1") { @@ -48,6 +54,8 @@ func (s *Server) Start() error { api.POST("/force-reload", s.forceReload) api.GET("/status", s.status) api.GET("/stats", s.stats) + api.GET("/logs", s.getLogs) + api.GET("/service-logs", s.getServiceLogs) } s.httpServer = &http.Server{ @@ -72,8 +80,8 @@ func (s *Server) Stop() error { // 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" { + // Skip auth for health and registration endpoints + if c.Request.URL.Path == "/health" || c.Request.URL.Path == "/api/v1/register" { c.Next() return } @@ -117,11 +125,25 @@ func (s *Server) applyConfig(c *gin.Context) { 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 + // Try to parse as JSON with optional panelUrl field + var requestData struct { + Config json.RawMessage `json:"config"` + PanelURL string `json:"panelUrl,omitempty"` + } + + // First try to parse as new format with panelUrl + if err := json.Unmarshal(body, &requestData); err == nil && requestData.PanelURL != "" { + // New format: { "config": {...}, "panelUrl": "http://..." } + body = requestData.Config + // Set panel URL for log pusher + nodeLogs.SetPanelURL(requestData.PanelURL) + } else { + // Old format: just JSON config, validate it + 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 { @@ -175,3 +197,107 @@ func (s *Server) stats(c *gin.Context) { c.JSON(http.StatusOK, stats) } + +// getLogs returns XRAY access logs from the node. +func (s *Server) getLogs(c *gin.Context) { + // Get query parameters + countStr := c.DefaultQuery("count", "100") + filter := c.DefaultQuery("filter", "") + + count, err := strconv.Atoi(countStr) + if err != nil || count < 1 || count > 10000 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid count parameter (must be 1-10000)"}) + return + } + + logs, err := s.xrayManager.GetLogs(count, filter) + if err != nil { + logger.Errorf("Failed to get logs: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"logs": logs}) +} + +// getServiceLogs returns service application logs from the node (node service logs and XRAY core logs). +func (s *Server) getServiceLogs(c *gin.Context) { + // Get query parameters + countStr := c.DefaultQuery("count", "100") + level := c.DefaultQuery("level", "debug") + + count, err := strconv.Atoi(countStr) + if err != nil || count < 1 || count > 10000 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid count parameter (must be 1-10000)"}) + return + } + + // Get logs from logger buffer + logs := logger.GetLogs(count, level) + c.JSON(http.StatusOK, gin.H{"logs": logs}) +} + +// register handles node registration from the panel. +// This endpoint receives an API key from the panel and saves it persistently. +// No authentication required - this is the initial setup step. +func (s *Server) register(c *gin.Context) { + type RegisterRequest struct { + ApiKey string `json:"apiKey" binding:"required"` // API key generated by panel + PanelURL string `json:"panelUrl,omitempty"` // Panel URL (optional) + NodeAddress string `json:"nodeAddress,omitempty"` // Node address (optional) + } + + var req RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + // Check if node is already registered + existingConfig := nodeConfig.GetConfig() + if existingConfig.ApiKey != "" { + logger.Warningf("Node is already registered. Rejecting registration attempt to prevent overwriting existing API key") + c.JSON(http.StatusConflict, gin.H{ + "error": "Node is already registered. API key cannot be overwritten", + "message": "This node has already been registered. If you need to re-register, please remove the node-config.json file first", + }) + return + } + + // Save API key to config file (only if not already registered) + if err := nodeConfig.SetApiKey(req.ApiKey, false); err != nil { + logger.Errorf("Failed to save API key: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save API key: " + err.Error()}) + return + } + + // Update API key in server (for immediate use) + s.apiKey = req.ApiKey + + // Save panel URL if provided + if req.PanelURL != "" { + if err := nodeConfig.SetPanelURL(req.PanelURL); err != nil { + logger.Warningf("Failed to save panel URL: %v", err) + } else { + // Update log pusher with new panel URL and API key + nodeLogs.SetPanelURL(req.PanelURL) + nodeLogs.UpdateApiKey(req.ApiKey) // Update API key in log pusher + } + } else { + // Even if panel URL is not provided, update API key in log pusher + nodeLogs.UpdateApiKey(req.ApiKey) + } + + // Save node address if provided + if req.NodeAddress != "" { + if err := nodeConfig.SetNodeAddress(req.NodeAddress); err != nil { + logger.Warningf("Failed to save node address: %v", err) + } + } + + logger.Infof("Node registered successfully with API key (length: %d)", len(req.ApiKey)) + c.JSON(http.StatusOK, gin.H{ + "message": "Node registered successfully", + "apiKey": req.ApiKey, // Return API key for confirmation + }) +} diff --git a/node/config/config.go b/node/config/config.go new file mode 100644 index 00000000..e59bd811 --- /dev/null +++ b/node/config/config.go @@ -0,0 +1,156 @@ +// Package config provides node configuration management, including API key persistence. +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" +) + +// NodeConfig represents the node's configuration stored on disk. +type NodeConfig struct { + ApiKey string `json:"apiKey"` // API key for authentication with panel + PanelURL string `json:"panelUrl"` // Panel URL (optional, can be set via env var) + NodeAddress string `json:"nodeAddress"` // Node's own address (optional) +} + +var ( + config *NodeConfig + configMu sync.RWMutex + configPath string +) + +// InitConfig initializes the configuration system and loads existing config if available. +// configDir is the directory where config file will be stored (e.g., "bin", "/app/bin"). +func InitConfig(configDir string) error { + configMu.Lock() + defer configMu.Unlock() + + // Determine config file path + if configDir == "" { + // Try common paths + possibleDirs := []string{"bin", "config", ".", "/app/bin", "/app/config"} + for _, dir := range possibleDirs { + if _, err := os.Stat(dir); err == nil { + configDir = dir + break + } + } + if configDir == "" { + configDir = "." // Fallback to current directory + } + } + + configPath = filepath.Join(configDir, "node-config.json") + + // Try to load existing config + if data, err := os.ReadFile(configPath); err == nil { + var loadedConfig NodeConfig + if err := json.Unmarshal(data, &loadedConfig); err == nil { + config = &loadedConfig + return nil + } + // If file exists but is invalid, we'll create a new one + } + + // Create empty config if file doesn't exist + config = &NodeConfig{} + return nil +} + +// GetConfig returns the current node configuration. +func GetConfig() *NodeConfig { + configMu.RLock() + defer configMu.RUnlock() + + if config == nil { + return &NodeConfig{} + } + + // Return a copy to prevent external modifications + return &NodeConfig{ + ApiKey: config.ApiKey, + PanelURL: config.PanelURL, + NodeAddress: config.NodeAddress, + } +} + +// SetApiKey sets the API key and saves it to disk. +// If an API key already exists, it will not be overwritten unless force is true. +func SetApiKey(apiKey string, force bool) error { + configMu.Lock() + defer configMu.Unlock() + + if config == nil { + config = &NodeConfig{} + } + + // Check if API key already exists + if config.ApiKey != "" && !force { + return fmt.Errorf("API key already exists. Use force=true to overwrite") + } + + config.ApiKey = apiKey + return saveConfig() +} + +// SetPanelURL sets the panel URL and saves it to disk. +func SetPanelURL(url string) error { + configMu.Lock() + defer configMu.Unlock() + + if config == nil { + config = &NodeConfig{} + } + + config.PanelURL = url + return saveConfig() +} + +// SetNodeAddress sets the node address and saves it to disk. +func SetNodeAddress(address string) error { + configMu.Lock() + defer configMu.Unlock() + + if config == nil { + config = &NodeConfig{} + } + + config.NodeAddress = address + return saveConfig() +} + +// saveConfig saves the current configuration to disk. +func saveConfig() error { + if configPath == "" { + return fmt.Errorf("config path not initialized, call InitConfig first") + } + + // Ensure directory exists + dir := filepath.Dir(configPath) + if err := os.MkdirAll(dir, 0750); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Marshal config to JSON + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + // Write to file with proper permissions (readable/writable by owner only) + if err := os.WriteFile(configPath, data, 0600); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +// GetConfigPath returns the path to the config file. +func GetConfigPath() string { + configMu.RLock() + defer configMu.RUnlock() + return configPath +} diff --git a/node/docker-compose.yml b/node/docker-compose.yml index 4d9b2803..72459803 100644 --- a/node/docker-compose.yml +++ b/node/docker-compose.yml @@ -7,7 +7,8 @@ services: restart: unless-stopped environment: # - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key} - - NODE_API_KEY=test-key + #- NODE_API_KEY=test-key + - PANEL_URL=http://192.168.0.7:2054 ports: - "8080:8080" - "44000:44000" @@ -26,7 +27,8 @@ services: restart: unless-stopped environment: # - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key} - - NODE_API_KEY=test-key + #- NODE_API_KEY=test-key1 + - PANEL_URL=http://192.168.0.7:2054 ports: - "8081:8080" - "44001:44001" @@ -45,7 +47,8 @@ services: container_name: 3x-ui-node3 restart: unless-stopped environment: - - NODE_API_KEY=test-key + #- NODE_API_KEY=test-key + - PANEL_URL=http://192.168.0.7:2054 ports: - "8082:8080" - "44002:44002" diff --git a/node/logs/pusher.go b/node/logs/pusher.go new file mode 100644 index 00000000..1da305fb --- /dev/null +++ b/node/logs/pusher.go @@ -0,0 +1,346 @@ +// Package logs provides log pushing functionality for sending logs from node to panel in real-time. +package logs + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v2/logger" +) + +// LogPusher sends logs to the panel in real-time. +type LogPusher struct { + panelURL string + apiKey string + nodeAddress string // Node's own address for identification + logBuffer []string + bufferMu sync.Mutex + client *http.Client + enabled bool + lastPush time.Time + pushTicker *time.Ticker + stopCh chan struct{} +} + +var ( + pusher *LogPusher + pusherOnce sync.Once + pusherMu sync.RWMutex +) + +// InitLogPusher initializes the log pusher if panel URL and API key are configured. +// nodeAddress is the address of this node (e.g., "http://192.168.0.7:8080") for identification. +func InitLogPusher(nodeAddress string) { + pusherOnce.Do(func() { + // Try to get API key from (in order of priority): + // 1. Environment variable + // 2. Saved config file + apiKey := os.Getenv("NODE_API_KEY") + if apiKey == "" { + // Try to load from saved config + cfg := getNodeConfig() + if cfg != nil && cfg.ApiKey != "" { + apiKey = cfg.ApiKey + logger.Debug("Using API key from saved configuration for log pusher") + } + } + + if apiKey == "" { + logger.Debug("Log pusher disabled: no API key found (will be enabled after registration)") + return + } + + // Try to get panel URL from environment variable first, then from saved config + panelURL := os.Getenv("PANEL_URL") + if panelURL == "" { + cfg := getNodeConfig() + if cfg != nil && cfg.PanelURL != "" { + panelURL = cfg.PanelURL + logger.Debug("Using panel URL from saved configuration for log pusher") + } + } + + pusher = &LogPusher{ + panelURL: panelURL, + apiKey: apiKey, + nodeAddress: nodeAddress, + logBuffer: make([]string, 0, 10), + client: &http.Client{ + Timeout: 5 * time.Second, + }, + enabled: panelURL != "", // Enable only if panel URL is set + stopCh: make(chan struct{}), + } + + if pusher.enabled { + // Start periodic push (every 2 seconds or when buffer is full) + pusher.pushTicker = time.NewTicker(2 * time.Second) + go pusher.run() + logger.Debugf("Log pusher initialized: sending logs to %s", panelURL) + } else { + logger.Debug("Log pusher initialized but disabled: waiting for panel URL") + } + }) +} + +// nodeConfigData represents the node configuration structure. +type nodeConfigData struct { + ApiKey string `json:"apiKey"` + PanelURL string `json:"panelUrl"` + NodeAddress string `json:"nodeAddress"` +} + +// getNodeConfig is a helper to get node config without circular dependency. +// It reads the config file directly to avoid importing the config package. +func getNodeConfig() *nodeConfigData { + configPaths := []string{"bin/node-config.json", "config/node-config.json", "./node-config.json", "/app/bin/node-config.json", "/app/config/node-config.json"} + + for _, path := range configPaths { + if data, err := os.ReadFile(path); err == nil { + var config nodeConfigData + if err := json.Unmarshal(data, &config); err == nil { + return &config + } + } + } + return nil +} + +// SetPanelURL sets the panel URL and enables the log pusher. +// PANEL_URL from environment variable has priority and won't be overwritten. +func SetPanelURL(url string) { + pusherMu.Lock() + defer pusherMu.Unlock() + + // Check if PANEL_URL is set in environment - it has priority + envPanelURL := os.Getenv("PANEL_URL") + if envPanelURL != "" { + // Environment variable has priority, ignore URL from config + if pusher != nil && pusher.panelURL == envPanelURL { + // Already set from env, don't update + return + } + // Use environment variable instead + url = envPanelURL + logger.Debugf("Using PANEL_URL from environment: %s (ignoring config URL)", envPanelURL) + } + + if pusher == nil { + // Initialize if not already initialized + apiKey := os.Getenv("NODE_API_KEY") + if apiKey == "" { + // Try to load from saved config + cfg := getNodeConfig() + if cfg != nil && cfg.ApiKey != "" { + apiKey = cfg.ApiKey + } + } + + if apiKey == "" { + logger.Debug("Cannot set panel URL: no API key found") + return + } + + // Get node address from environment if not provided + nodeAddress := os.Getenv("NODE_ADDRESS") + if nodeAddress == "" { + cfg := getNodeConfig() + if cfg != nil && cfg.NodeAddress != "" { + nodeAddress = cfg.NodeAddress + } + } + + pusher = &LogPusher{ + apiKey: apiKey, + nodeAddress: nodeAddress, + logBuffer: make([]string, 0, 10), + client: &http.Client{ + Timeout: 5 * time.Second, + }, + stopCh: make(chan struct{}), + } + } + + if url == "" { + logger.Debug("Panel URL cleared, disabling log pusher") + pusher.enabled = false + if pusher.pushTicker != nil { + pusher.pushTicker.Stop() + pusher.pushTicker = nil + } + return + } + + wasEnabled := pusher.enabled + pusher.panelURL = url + pusher.enabled = true + + if !wasEnabled && pusher.pushTicker == nil { + // Start periodic push if it wasn't running + pusher.pushTicker = time.NewTicker(2 * time.Second) + go pusher.run() + logger.Debugf("Log pusher enabled: sending logs to %s", url) + } else if wasEnabled && pusher.panelURL != url { + logger.Debugf("Log pusher panel URL updated: %s", url) + } +} + +// UpdateApiKey updates the API key in the log pusher. +// This is called after node registration to enable log pushing. +func UpdateApiKey(apiKey string) { + pusherMu.Lock() + defer pusherMu.Unlock() + + if pusher == nil { + logger.Debug("Cannot update API key: log pusher not initialized") + return + } + + pusher.apiKey = apiKey + logger.Debugf("Log pusher API key updated (length: %d)", len(apiKey)) + + // If pusher is enabled but wasn't running, start it + if pusher.enabled && pusher.pushTicker == nil && pusher.panelURL != "" { + pusher.pushTicker = time.NewTicker(2 * time.Second) + go pusher.run() + logger.Debugf("Log pusher started after API key update") + } +} + +// PushLog adds a log entry to the buffer for sending to panel. +func PushLog(logLine string) { + if pusher == nil || !pusher.enabled { + return + } + + // Skip logs that already contain node prefix to avoid infinite loop + // These are logs that came from panel and shouldn't be sent back + if strings.Contains(logLine, "[Node:") { + return + } + + // Skip logs about log pushing itself to avoid infinite loop + if strings.Contains(logLine, "Logs pushed:") || strings.Contains(logLine, "Failed to push logs") { + return + } + + pusher.bufferMu.Lock() + defer pusher.bufferMu.Unlock() + + pusher.logBuffer = append(pusher.logBuffer, logLine) + + // If buffer is getting large, push immediately + if len(pusher.logBuffer) >= 10 { + go pusher.push() + } +} + +// run periodically pushes logs to panel. +func (lp *LogPusher) run() { + for { + select { + case <-lp.pushTicker.C: + lp.bufferMu.Lock() + if len(lp.logBuffer) > 0 { + logsToPush := make([]string, len(lp.logBuffer)) + copy(logsToPush, lp.logBuffer) + lp.logBuffer = lp.logBuffer[:0] + lp.bufferMu.Unlock() + + go lp.pushLogs(logsToPush) + } else { + lp.bufferMu.Unlock() + } + case <-lp.stopCh: + return + } + } +} + +// push immediately pushes current buffer to panel. +func (lp *LogPusher) push() { + lp.bufferMu.Lock() + if len(lp.logBuffer) == 0 { + lp.bufferMu.Unlock() + return + } + + logsToPush := make([]string, len(lp.logBuffer)) + copy(logsToPush, lp.logBuffer) + lp.logBuffer = lp.logBuffer[:0] + lp.bufferMu.Unlock() + + lp.pushLogs(logsToPush) +} + +// pushLogs sends logs to the panel. +func (lp *LogPusher) pushLogs(logs []string) { + if len(logs) == 0 { + return + } + + // Construct panel URL + panelEndpoint := lp.panelURL + if panelEndpoint[len(panelEndpoint)-1] != '/' { + panelEndpoint += "/" + } + panelEndpoint += "panel/api/node/push-logs" + + // Log push attempt (DEBUG level to avoid sending this log back to panel) + logger.Debugf("Logs pushed: %d log entries to %s", len(logs), panelEndpoint) + + // Prepare request + reqBody := map[string]interface{}{ + "apiKey": lp.apiKey, + "logs": logs, + } + // Add node address for identification (in case multiple nodes share the same API key) + if lp.nodeAddress != "" { + reqBody["nodeAddress"] = lp.nodeAddress + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + logger.Errorf("Failed to marshal log push request to %s: %v", panelEndpoint, err) + return + } + + req, err := http.NewRequest("POST", panelEndpoint, bytes.NewBuffer(jsonData)) + if err != nil { + logger.Errorf("Failed to create log push request to %s: %v", panelEndpoint, err) + return + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := lp.client.Do(req) + if err != nil { + logger.Errorf("Failed to push logs to panel at %s: %v (check if panel URL is correct and accessible)", panelEndpoint, err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + logger.Errorf("Panel at %s returned non-OK status %d for log push: %s", panelEndpoint, resp.StatusCode, string(body)) + return + } + + lp.lastPush = time.Now() +} + +// Stop stops the log pusher. +func Stop() { + if pusher != nil && pusher.pushTicker != nil { + pusher.pushTicker.Stop() + close(pusher.stopCh) + // Push remaining logs + pusher.push() + } +} diff --git a/node/main.go b/node/main.go index 617981f2..d64bef8e 100644 --- a/node/main.go +++ b/node/main.go @@ -4,6 +4,7 @@ package main import ( "flag" + "fmt" "log" "os" "os/signal" @@ -11,27 +12,88 @@ import ( "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/node/api" + nodeConfig "github.com/mhsanaei/3x-ui/v2/node/config" + nodeLogs "github.com/mhsanaei/3x-ui/v2/node/logs" "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.StringVar(&apiKey, "api-key", "", "API key for authentication (optional, can be set via registration)") flag.Parse() - // Check environment variable if flag is not provided + logger.InitLogger(logging.INFO) + + // Initialize node configuration system + // Try to find config directory (same as XRAY config) + configDirs := []string{"bin", "config", ".", "/app/bin", "/app/config"} + var configDir string + for _, dir := range configDirs { + if _, err := os.Stat(dir); err == nil { + configDir = dir + break + } + } + if configDir == "" { + configDir = "." // Fallback + } + + if err := nodeConfig.InitConfig(configDir); err != nil { + log.Fatalf("Failed to initialize node config: %v", err) + } + + // Get API key from (in order of priority): + // 1. Command line flag + // 2. Environment variable (for backward compatibility) + // 3. Saved config file (from registration) 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") + // Try to load from saved config + savedConfig := nodeConfig.GetConfig() + if savedConfig.ApiKey != "" { + apiKey = savedConfig.ApiKey + log.Printf("Using API key from saved configuration") + } } - logger.InitLogger(logging.INFO) + // If still no API key, node can start but will need registration + if apiKey == "" { + log.Printf("WARNING: No API key found. Node will need to be registered via /api/v1/register endpoint") + log.Printf("You can set NODE_API_KEY environment variable or use -api-key flag for immediate use") + // Use a temporary key that will be replaced during registration + apiKey = "temp-unregistered" + } + + // Initialize log pusher if panel URL is configured + // Get node address from saved config or environment variable + savedConfig := nodeConfig.GetConfig() + nodeAddress := savedConfig.NodeAddress + if nodeAddress == "" { + nodeAddress = os.Getenv("NODE_ADDRESS") + } + if nodeAddress == "" { + // Default to localhost with the port (panel will match by port if address doesn't match exactly) + nodeAddress = fmt.Sprintf("http://127.0.0.1:%d", port) + } + + // Get panel URL from saved config or environment variable + panelURL := savedConfig.PanelURL + if panelURL == "" { + panelURL = os.Getenv("PANEL_URL") + } + + nodeLogs.InitLogPusher(nodeAddress) + if panelURL != "" { + nodeLogs.SetPanelURL(panelURL) + } + // Connect log pusher to logger + logger.SetLogPusher(nodeLogs.PushLog) xrayManager := xray.NewManager() server := api.NewServer(port, apiKey, xrayManager) diff --git a/node/xray/manager.go b/node/xray/manager.go index a8522275..c8ced5f3 100644 --- a/node/xray/manager.go +++ b/node/xray/manager.go @@ -2,6 +2,7 @@ package xray import ( + "bufio" "encoding/json" "errors" "fmt" @@ -9,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "sync" "time" @@ -469,3 +471,71 @@ func (m *Manager) GetStats(reset bool) (*NodeStats, error) { OnlineClients: onlineList, }, nil } + +// GetLogs returns XRAY access logs from the log file. +// Returns raw log lines as strings. +func (m *Manager) GetLogs(count int, filter string) ([]string, error) { + m.lock.Lock() + defer m.lock.Unlock() + + if m.process == nil || !m.process.IsRunning() { + return nil, errors.New("XRAY is not running") + } + + // Get access log path from current config + var pathToAccessLog string + if m.config != nil && len(m.config.LogConfig) > 0 { + var logConfig map[string]interface{} + if err := json.Unmarshal(m.config.LogConfig, &logConfig); err == nil { + if access, ok := logConfig["access"].(string); ok { + pathToAccessLog = access + } + } + } + + // Fallback to reading from file if not in config + if pathToAccessLog == "" { + var err error + pathToAccessLog, err = xray.GetAccessLogPath() + if err != nil { + return nil, fmt.Errorf("failed to get access log path: %w", err) + } + } + + if pathToAccessLog == "none" || pathToAccessLog == "" { + return []string{}, nil // No logs configured + } + + file, err := os.Open(pathToAccessLog) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + defer file.Close() + + var lines []string + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.Contains(line, "api -> api") { + continue // Skip empty lines and API calls + } + + if filter != "" && !strings.Contains(line, filter) { + continue // Apply filter if provided + } + + lines = append(lines, line) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read log file: %w", err) + } + + // Return last 'count' lines + if len(lines) > count { + lines = lines[len(lines)-count:] + } + + return lines, nil +} \ No newline at end of file diff --git a/web/controller/api.go b/web/controller/api.go index 1a39f8ed..6978a3af 100644 --- a/web/controller/api.go +++ b/web/controller/api.go @@ -1,8 +1,13 @@ package controller import ( + "fmt" "net/http" + "regexp" + "strings" + "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" @@ -36,7 +41,12 @@ func (a *APIController) checkAPIAuth(c *gin.Context) { // initRouter sets up the API routes for inbounds, server, and other endpoints. func (a *APIController) initRouter(g *gin.RouterGroup) { - // Main API group + // Node push-logs endpoint (no session auth, uses API key) + // Register in separate group without session auth middleware + nodeAPI := g.Group("/panel/api/node") + nodeAPI.POST("/push-logs", a.pushNodeLogs) + + // Main API group with session auth api := g.Group("/panel/api") api.Use(a.checkAPIAuth) @@ -56,3 +66,149 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { func (a *APIController) BackuptoTgbot(c *gin.Context) { a.Tgbot.SendBackupToAdmins() } + +// extractPort extracts port number from URL address (e.g., "http://192.168.0.7:8080" -> "8080") +func extractPort(address string) string { + re := regexp.MustCompile(`:(\d+)(?:/|$)`) + matches := re.FindStringSubmatch(address) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// pushNodeLogs receives logs from a node in real-time and adds them to the panel log buffer. +// This endpoint is called by nodes when new logs are generated. +// It uses API key authentication instead of session authentication. +func (a *APIController) pushNodeLogs(c *gin.Context) { + type PushLogRequest struct { + ApiKey string `json:"apiKey" binding:"required"` // Node API key for authentication + NodeAddress string `json:"nodeAddress,omitempty"` // Node's own address for identification (optional, used when multiple nodes share API key) + Logs []string `json:"logs" binding:"required"` // Array of log lines in format "timestamp level - message" + } + + var req PushLogRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + // Find node by API key and optionally by address + nodeService := service.NodeService{} + nodes, err := nodeService.GetAllNodes() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get nodes"}) + return + } + + var node *model.Node + var matchedByKey []*model.Node // Track nodes with matching API key + + for _, n := range nodes { + if n.ApiKey == req.ApiKey { + matchedByKey = append(matchedByKey, n) + + // If nodeAddress is provided, match by both API key and address + if req.NodeAddress != "" { + // Normalize addresses for comparison (remove trailing slashes, etc.) + nodeAddr := strings.TrimSuffix(strings.TrimSpace(n.Address), "/") + reqAddr := strings.TrimSuffix(strings.TrimSpace(req.NodeAddress), "/") + + // Extract port from both addresses for comparison + // This handles cases where node uses localhost but panel has external IP + nodePort := extractPort(nodeAddr) + reqPort := extractPort(reqAddr) + + // Match by exact address or by port (if addresses don't match exactly) + // This allows nodes to use localhost while panel has external IP + if nodeAddr == reqAddr || (nodePort != "" && nodePort == reqPort) { + node = n + break + } + } else { + // If no address provided, use first match (backward compatibility) + node = n + break + } + } + } + + if node == nil { + // Enhanced logging for debugging + if len(matchedByKey) > 0 { + logger.Debugf("Failed to find node: API key matches %d node(s), but address mismatch. Request address: '%s', Request port: '%s'. Matched nodes: %v", + len(matchedByKey), req.NodeAddress, extractPort(req.NodeAddress), + func() []string { + var addrs []string + for _, n := range matchedByKey { + addrs = append(addrs, fmt.Sprintf("%s (port: %s)", n.Address, extractPort(n.Address))) + } + return addrs + }()) + } else { + logger.Debugf("Failed to find node: No node found with API key (received %d logs, key length: %d, key prefix: %s). Total nodes in DB: %d", + len(req.Logs), len(req.ApiKey), + func() string { + if len(req.ApiKey) > 4 { + return req.ApiKey[:4] + "..." + } + return req.ApiKey + }(), len(nodes)) + } + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"}) + return + } + + // Log which node is sending logs (for debugging) + logger.Debugf("Received %d logs from node: %s (ID: %d, Address: %s, API key length: %d)", + len(req.Logs), node.Name, node.Id, node.Address, len(req.ApiKey)) + + // Process and add logs to panel buffer + for _, logLine := range req.Logs { + if logLine == "" { + continue + } + + // Parse log line: format is "timestamp level - message" + var level string + var message string + + if idx := strings.Index(logLine, " - "); idx != -1 { + parts := strings.SplitN(logLine, " - ", 2) + if len(parts) == 2 { + levelPart := strings.TrimSpace(parts[0]) + levelFields := strings.Fields(levelPart) + if len(levelFields) >= 2 { + level = strings.ToUpper(levelFields[len(levelFields)-1]) + message = parts[1] + } else { + level = "INFO" + message = parts[1] + } + } else { + level = "INFO" + message = logLine + } + } else { + level = "INFO" + message = logLine + } + + // Add log to panel buffer with node prefix + formattedMessage := fmt.Sprintf("[Node: %s] %s", node.Name, message) + switch level { + case "DEBUG": + logger.Debugf("%s", formattedMessage) + case "WARNING": + logger.Warningf("%s", formattedMessage) + case "ERROR": + logger.Errorf("%s", formattedMessage) + case "NOTICE": + logger.Noticef("%s", formattedMessage) + default: + logger.Infof("%s", formattedMessage) + } + } + + c.JSON(http.StatusOK, gin.H{"message": "Logs received"}) +} diff --git a/web/controller/node.go b/web/controller/node.go index 11180d87..895c9b6f 100644 --- a/web/controller/node.go +++ b/web/controller/node.go @@ -2,11 +2,15 @@ package controller import ( + "fmt" "strconv" + "strings" + "time" "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/websocket" "github.com/gin-gonic/gin" ) @@ -37,6 +41,9 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) { g.POST("/reload/:id", a.reloadNode) g.POST("/reloadAll", a.reloadAllNodes) g.GET("/status/:id", a.getNodeStatus) + g.POST("/logs/:id", a.getNodeLogs) + g.POST("/check-connection", a.checkNodeConnection) // Check node connection without API key + // push-logs endpoint moved to APIController to bypass session auth } // getNodes retrieves the list of all nodes. @@ -80,7 +87,7 @@ func (a *NodeController) getNode(c *gin.Context) { jsonObj(c, node, nil) } -// addNode creates a new node. +// addNode creates a new node and registers it with a generated API key. func (a *NodeController) addNode(c *gin.Context) { node := &model.Node{} err := c.ShouldBind(node) @@ -90,31 +97,42 @@ func (a *NodeController) addNode(c *gin.Context) { } // Log received data for debugging - logger.Debugf("Adding node: name=%s, address=%s, apiKey=%s", node.Name, node.Address, node.ApiKey) + logger.Debugf("[Node: %s] Adding node: address=%s", node.Name, node.Address) - // Validate API key before saving - err = a.nodeService.ValidateApiKey(node) + // Note: Connection check is done on frontend via /panel/node/check-connection endpoint + // to avoid CORS issues. Here we proceed directly to registration. + + // Generate API key and register node + apiKey, err := a.nodeService.RegisterNode(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) + logger.Errorf("[Node: %s] Registration failed: %v", node.Name, err) + jsonMsg(c, "Failed to register node: "+err.Error(), err) return } + // Set the generated API key + node.ApiKey = apiKey + // Set default status if node.Status == "" { node.Status = "unknown" } + // Save node to database err = a.nodeService.AddNode(node) if err != nil { - jsonMsg(c, "Failed to add node", err) + jsonMsg(c, "Failed to add node to database", err) return } // Check health immediately go a.nodeService.CheckNodeHealth(node) - jsonMsgObj(c, "Node added successfully", node, nil) + // Broadcast nodes update via WebSocket + a.broadcastNodesUpdate() + + logger.Infof("[Node: %s] Node added and registered successfully", node.Name) + jsonMsgObj(c, "Node added and registered successfully", node, nil) } // updateNode updates an existing node. @@ -211,6 +229,9 @@ func (a *NodeController) updateNode(c *gin.Context) { return } + // Broadcast nodes update via WebSocket + a.broadcastNodesUpdate() + jsonMsgObj(c, "Node updated successfully", node, nil) } @@ -228,6 +249,9 @@ func (a *NodeController) deleteNode(c *gin.Context) { return } + // Broadcast nodes update via WebSocket + a.broadcastNodesUpdate() + jsonMsg(c, "Node deleted successfully", nil) } @@ -251,12 +275,20 @@ func (a *NodeController) checkNode(c *gin.Context) { return } + // Broadcast nodes update via WebSocket (to update status and response time) + a.broadcastNodesUpdate() + 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() + // Broadcast nodes update after health check (with delay to allow all checks to complete) + go func() { + time.Sleep(3 * time.Second) // Wait for health checks to complete + a.broadcastNodesUpdate() + }() jsonMsg(c, "Health check initiated for all nodes", nil) } @@ -317,3 +349,213 @@ func (a *NodeController) reloadAllNodes(c *gin.Context) { jsonMsg(c, "All nodes reloaded successfully", nil) } + +// getNodeLogs retrieves XRAY logs from a specific node. +func (a *NodeController) getNodeLogs(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 + } + + count := c.DefaultPostForm("count", "100") + filter := c.PostForm("filter") + showDirect := c.DefaultPostForm("showDirect", "true") + showBlocked := c.DefaultPostForm("showBlocked", "true") + showProxy := c.DefaultPostForm("showProxy", "true") + + countInt, _ := strconv.Atoi(count) + + // Get raw logs from node + rawLogs, err := a.nodeService.GetNodeLogs(node, countInt, filter) + if err != nil { + jsonMsg(c, "Failed to get logs from node", err) + return + } + + // Parse logs into LogEntry format (similar to ServerService.GetXrayLogs) + type LogEntry struct { + DateTime time.Time `json:"DateTime"` + FromAddress string `json:"FromAddress"` + ToAddress string `json:"ToAddress"` + Inbound string `json:"Inbound"` + Outbound string `json:"Outbound"` + Email string `json:"Email"` + Event int `json:"Event"` + } + + const ( + Direct = iota + Blocked + Proxied + ) + + var freedoms []string + var blackholes []string + + // Get tags for freedom and blackhole outbounds from default config + settingService := service.SettingService{} + config, err := settingService.GetDefaultXrayConfig() + if err == nil && config != nil { + if cfgMap, ok := config.(map[string]any); ok { + if outbounds, ok := cfgMap["outbounds"].([]any); ok { + for _, outbound := range outbounds { + if obMap, ok := outbound.(map[string]any); ok { + switch obMap["protocol"] { + case "freedom": + if tag, ok := obMap["tag"].(string); ok { + freedoms = append(freedoms, tag) + } + case "blackhole": + if tag, ok := obMap["tag"].(string); ok { + blackholes = append(blackholes, tag) + } + } + } + } + } + } + } + + if len(freedoms) == 0 { + freedoms = []string{"direct"} + } + if len(blackholes) == 0 { + blackholes = []string{"blocked"} + } + + var entries []LogEntry + for _, line := range rawLogs { + var entry LogEntry + parts := strings.Fields(line) + + for i, part := range parts { + if i == 0 && len(parts) > 1 { + dateTime, err := time.ParseInLocation("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1], time.Local) + if err == nil { + entry.DateTime = dateTime.UTC() + } + } + + if part == "from" && i+1 < len(parts) { + entry.FromAddress = strings.TrimLeft(parts[i+1], "/") + } else if part == "accepted" && i+1 < len(parts) { + entry.ToAddress = strings.TrimLeft(parts[i+1], "/") + } else if strings.HasPrefix(part, "[") { + entry.Inbound = part[1:] + } else if strings.HasSuffix(part, "]") { + entry.Outbound = part[:len(part)-1] + } else if part == "email:" && i+1 < len(parts) { + entry.Email = parts[i+1] + } + } + + // Determine event type + logEntryContains := func(line string, suffixes []string) bool { + for _, sfx := range suffixes { + if strings.Contains(line, sfx+"]") { + return true + } + } + return false + } + + if logEntryContains(line, freedoms) { + if showDirect == "false" { + continue + } + entry.Event = Direct + } else if logEntryContains(line, blackholes) { + if showBlocked == "false" { + continue + } + entry.Event = Blocked + } else { + if showProxy == "false" { + continue + } + entry.Event = Proxied + } + + entries = append(entries, entry) + } + + jsonObj(c, entries, nil) +} + +// checkNodeConnection checks if a node is reachable (health check without API key). +// This is used during node registration to verify connectivity before registration. +func (a *NodeController) checkNodeConnection(c *gin.Context) { + type CheckConnectionRequest struct { + Address string `json:"address" form:"address" binding:"required"` + } + + var req CheckConnectionRequest + // HttpUtil.post sends data as form-urlencoded (see axios-init.js) + // So we use ShouldBind which handles both form and JSON + if err := c.ShouldBind(&req); err != nil { + jsonMsg(c, "Invalid request: "+err.Error(), err) + return + } + + if req.Address == "" { + jsonMsg(c, "Address is required", nil) + return + } + + // Create a temporary node object for health check + tempNode := &model.Node{ + Address: req.Address, + } + + // Check node health (this only uses /health endpoint, no API key required) + status, responseTime, err := a.nodeService.CheckNodeStatus(tempNode) + if err != nil { + jsonMsg(c, "Node is not reachable: "+err.Error(), err) + return + } + + if status != "online" { + jsonMsg(c, "Node is not online (status: "+status+")", nil) + return + } + + // Return response time along with success message + jsonMsgObj(c, fmt.Sprintf("Node is reachable (response time: %d ms)", responseTime), map[string]interface{}{ + "responseTime": responseTime, + }, nil) +} + +// broadcastNodesUpdate broadcasts the current nodes list to all WebSocket clients +func (a *NodeController) broadcastNodesUpdate() { + // Get all nodes with their inbounds + nodes, err := a.nodeService.GetAllNodes() + if err != nil { + logger.Warningf("Failed to get nodes for WebSocket broadcast: %v", 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, + }) + } + + // Broadcast via WebSocket + websocket.BroadcastNodes(result) +} diff --git a/web/controller/server.go b/web/controller/server.go index d32209e1..fc2f915a 100644 --- a/web/controller/server.go +++ b/web/controller/server.go @@ -237,7 +237,8 @@ func (a *ServerController) getXrayLogs(c *gin.Context) { blackholes = []string{"blocked"} } - logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes) + nodeId := c.PostForm("nodeId") + logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes, nodeId) jsonObj(c, logs, nil) } diff --git a/web/html/index.html b/web/html/index.html index 65d5ab92..c214d962 100644 --- a/web/html/index.html +++ b/web/html/index.html @@ -392,6 +392,15 @@ + + + All Nodes + + [[ node.name || 'Node ' + node.id ]] + + + ({ + id: node.id, + name: node.name || 'Node ' + node.id, + address: node.address || '', + status: node.status || 'unknown' + })); + } + } catch (e) { + console.warn("Failed to load nodes for logs:", e); + } + }, async getStatus() { try { const msg = await HttpUtil.get('/panel/api/server/status'); @@ -1075,12 +1108,45 @@ logModal.loading = false; }, async openXrayLogs() { - xraylogModal.loading = true; - const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, { filter: xraylogModal.filter, showDirect: xraylogModal.showDirect, showBlocked: xraylogModal.showBlocked, showProxy: xraylogModal.showProxy }); - if (!msg.success) { - return; + // Ensure multi-node mode is loaded and nodes are available + if (this.multiNodeMode && xraylogModal.nodes.length === 0) { + await this.loadNodesForLogs(); + } + + xraylogModal.loading = true; + const params = { + filter: xraylogModal.filter, + showDirect: xraylogModal.showDirect, + showBlocked: xraylogModal.showBlocked, + showProxy: xraylogModal.showProxy + }; + + // If multi-node mode and nodeId is selected, use node-specific endpoint + if (this.multiNodeMode && xraylogModal.nodeId) { + const msg = await HttpUtil.post('/panel/node/logs/' + xraylogModal.nodeId, { + count: xraylogModal.rows, + filter: xraylogModal.filter, + showDirect: xraylogModal.showDirect, + showBlocked: xraylogModal.showBlocked, + showProxy: xraylogModal.showProxy + }); + if (!msg.success) { + xraylogModal.loading = false; + return; + } + xraylogModal.show(msg.obj); + } else { + // Use standard endpoint with optional nodeId + if (xraylogModal.nodeId) { + params.nodeId = xraylogModal.nodeId; + } + const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, params); + if (!msg.success) { + xraylogModal.loading = false; + return; + } + xraylogModal.show(msg.obj); } - xraylogModal.show(msg.obj); await PromiseUtil.sleep(500); xraylogModal.loading = false; }, @@ -1165,6 +1231,13 @@ }, 2000); }, }, + watch: { + 'xraylogModal.visible'(newVal) { + if (newVal && this.multiNodeMode && xraylogModal.nodes.length === 0) { + this.loadNodesForLogs(); + } + } + }, async mounted() { if (window.location.protocol !== "https:") { this.showAlert = true; diff --git a/web/html/modals/node_modal.html b/web/html/modals/node_modal.html index a840bc52..fd321fd7 100644 --- a/web/html/modals/node_modal.html +++ b/web/html/modals/node_modal.html @@ -1,31 +1,73 @@ {{define "modals/nodeModal"}} - - - - - - - - - - - - - - + @ok="nodeModal.ok" @cancel="nodeModal.cancel" :ok-text="nodeModal.okText" :width="600" + :confirm-loading="nodeModal.registering" :ok-button-props="{ disabled: nodeModal.registering }"> +
+ + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + +