diff --git a/web/controller/api.go b/web/controller/api.go index 1a39f8ed..b7524cce 100644 --- a/web/controller/api.go +++ b/web/controller/api.go @@ -56,3 +56,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { func (a *APIController) BackuptoTgbot(c *gin.Context) { a.Tgbot.SendBackupToAdmins() } + +// StatusProvider returns a function that provides the most recently collected server status. +// Used by the WebSocket controller to push status to newly connected clients. +func (a *APIController) StatusProvider() func() any { + return a.serverController.GetLastStatus +} diff --git a/web/controller/server.go b/web/controller/server.go index d32209e1..a3d46f1a 100644 --- a/web/controller/server.go +++ b/web/controller/server.go @@ -5,6 +5,7 @@ import ( "net/http" "regexp" "strconv" + "sync" "time" "github.com/mhsanaei/3x-ui/v2/web/global" @@ -23,6 +24,7 @@ type ServerController struct { serverService service.ServerService settingService service.SettingService + mu sync.RWMutex lastStatus *service.Status lastVersions []string @@ -64,17 +66,32 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) { // refreshStatus updates the cached server status and collects CPU history. func (a *ServerController) refreshStatus() { - a.lastStatus = a.serverService.GetStatus(a.lastStatus) + a.mu.RLock() + last := a.lastStatus + a.mu.RUnlock() + + fresh := a.serverService.GetStatus(last) + + a.mu.Lock() + a.lastStatus = fresh + a.mu.Unlock() + // collect cpu history when status is fresh - if a.lastStatus != nil { - a.serverService.AppendCpuSample(time.Now(), a.lastStatus.Cpu) + if fresh != nil { + a.serverService.AppendCpuSample(time.Now(), fresh.Cpu) // Broadcast status update via WebSocket - websocket.BroadcastStatus(a.lastStatus) + websocket.BroadcastStatus(fresh) } } // startTask initiates background tasks for continuous status monitoring. +// It also triggers an immediate first refresh in the background so that +// a.lastStatus is populated well before the first cron tick. func (a *ServerController) startTask() { + // Populate lastStatus immediately (in background) so the first HTTP poll + // or WebSocket connection does not receive a null response. + go a.refreshStatus() + webServer := global.GetWebServer() c := webServer.GetCron() c.AddFunc("@every 2s", func() { @@ -85,7 +102,23 @@ func (a *ServerController) startTask() { } // status returns the current server status information. -func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) } +func (a *ServerController) status(c *gin.Context) { + a.mu.RLock() + s := a.lastStatus + a.mu.RUnlock() + jsonObj(c, s, nil) +} + +// GetLastStatus returns the most recently collected server status. +// The returned value may be nil if the first collection has not yet completed. +func (a *ServerController) GetLastStatus() any { + a.mu.RLock() + defer a.mu.RUnlock() + if a.lastStatus == nil { + return nil + } + return a.lastStatus +} // getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket. func (a *ServerController) getCpuHistoryBucket(c *gin.Context) { diff --git a/web/controller/websocket.go b/web/controller/websocket.go index 0ad5c845..dbb64985 100644 --- a/web/controller/websocket.go +++ b/web/controller/websocket.go @@ -68,13 +68,17 @@ var upgrader = ws.Upgrader{ // WebSocketController handles WebSocket connections for real-time updates type WebSocketController struct { BaseController - hub *websocket.Hub + hub *websocket.Hub + statusProvider func() any // returns current server status (may be nil) } -// NewWebSocketController creates a new WebSocket controller -func NewWebSocketController(hub *websocket.Hub) *WebSocketController { +// NewWebSocketController creates a new WebSocket controller. +// statusProvider is an optional function that returns the current server status; +// if provided, the status is pushed immediately to each newly connected client. +func NewWebSocketController(hub *websocket.Hub, statusProvider func() any) *WebSocketController { return &WebSocketController{ - hub: hub, + hub: hub, + statusProvider: statusProvider, } } @@ -107,6 +111,14 @@ func (w *WebSocketController) HandleWebSocket(c *gin.Context) { w.hub.Register(client) logger.Debugf("WebSocket client %s registered from %s", clientID, getRemoteIp(c)) + // Push current status immediately so the frontend does not have to wait + // for the next 2-second cron tick to clear the loading screen. + if w.statusProvider != nil { + if s := w.statusProvider(); s != nil { + websocket.SendStatusToClient(client, s) + } + } + // Start goroutines for reading and writing go w.writePump(client, conn) go w.readPump(client, conn) diff --git a/web/html/index.html b/web/html/index.html index 517bac14..757da595 100644 --- a/web/html/index.html +++ b/web/html/index.html @@ -904,8 +904,12 @@ async getStatus() { try { const msg = await HttpUtil.get('/panel/api/server/status'); - if (msg.success) { + if (msg.success && msg.obj !== null && msg.obj !== undefined) { this.setStatus(msg.obj); + } else if (msg.success && msg.obj === null) { + // Server started but status not yet collected – return without + // marking fetched so the caller can decide to retry or fall back. + return false; } else if (!this.loadingStates.fetched) { this.loadingStates.fetched = true; } @@ -915,6 +919,7 @@ this.loadingStates.fetched = true; } } + return true; }, setStatus(data) { this.loadingStates.fetched = true; @@ -1129,8 +1134,29 @@ this.ipLimitEnable = msg.obj.ipLimitEnable; } - // Initial status fetch - await this.getStatus(); + // Initial status fetch – retry up to 5 times (500 ms apart) if the server + // returns null (status not yet collected on startup). A hard failsafe + // timeout of 6 seconds guarantees the loading screen always clears. + await (async () => { + const failsafeTimer = setTimeout(() => { + if (!this.loadingStates.fetched) { + this.loadingStates.fetched = true; + } + }, 6000); + const maxRetries = 5; + for (let i = 0; i < maxRetries; i++) { + const ok = await this.getStatus(); + if (ok || this.loadingStates.fetched) { + clearTimeout(failsafeTimer); + return; + } + // Null status – wait briefly then retry + await new Promise(resolve => setTimeout(resolve, 500)); + } + // All retries exhausted – show zeroed data + clearTimeout(failsafeTimer); + if (!this.loadingStates.fetched) this.loadingStates.fetched = true; + })(); // Setup WebSocket for real-time updates if (window.wsClient) { diff --git a/web/service/server.go b/web/service/server.go index 1a3590ed..ed5deafa 100644 --- a/web/service/server.go +++ b/web/service/server.go @@ -107,6 +107,7 @@ type ServerService struct { cachedIPv4 string cachedIPv6 string noIPv6 bool + ipFetchOnce sync.Once mu sync.Mutex lastCPUTimes cpu.TimesStat hasLastCPUSample bool @@ -346,47 +347,54 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status { logger.Warning("get udp connections failed:", err) } - // IP fetching with caching - showIp4ServiceLists := []string{ - "https://api4.ipify.org", - "https://ipv4.icanhazip.com", - "https://v4.api.ipinfo.io/ip", - "https://ipv4.myexternalip.com/raw", - "https://4.ident.me", - "https://check-host.net/ip", - } - showIp6ServiceLists := []string{ - "https://api6.ipify.org", - "https://ipv6.icanhazip.com", - "https://v6.api.ipinfo.io/ip", - "https://ipv6.myexternalip.com/raw", - "https://6.ident.me", - } - - if s.cachedIPv4 == "" { - for _, ip4Service := range showIp4ServiceLists { - s.cachedIPv4 = getPublicIP(ip4Service) - if s.cachedIPv4 != "N/A" { - break + // IP fetching with caching — resolved once in a background goroutine to avoid + // blocking the status collector on the very first call (could take up to 33 s). + s.ipFetchOnce.Do(func() { + go func() { + showIp4ServiceLists := []string{ + "https://api4.ipify.org", + "https://ipv4.icanhazip.com", + "https://v4.api.ipinfo.io/ip", + "https://ipv4.myexternalip.com/raw", + "https://4.ident.me", + "https://check-host.net/ip", } - } - } - - if s.cachedIPv6 == "" && !s.noIPv6 { - for _, ip6Service := range showIp6ServiceLists { - s.cachedIPv6 = getPublicIP(ip6Service) - if s.cachedIPv6 != "N/A" { - break + showIp6ServiceLists := []string{ + "https://api6.ipify.org", + "https://ipv6.icanhazip.com", + "https://v6.api.ipinfo.io/ip", + "https://ipv6.myexternalip.com/raw", + "https://6.ident.me", } - } - } - if s.cachedIPv6 == "N/A" { - s.noIPv6 = true - } + var ipv4, ipv6 string + for _, svc := range showIp4ServiceLists { + ipv4 = getPublicIP(svc) + if ipv4 != "N/A" { + break + } + } + for _, svc := range showIp6ServiceLists { + ipv6 = getPublicIP(svc) + if ipv6 != "N/A" { + break + } + } + s.mu.Lock() + s.cachedIPv4 = ipv4 + s.cachedIPv6 = ipv6 + if ipv6 == "N/A" { + s.noIPv6 = true + } + s.mu.Unlock() + }() + }) + + s.mu.Lock() status.PublicIP.IPv4 = s.cachedIPv4 status.PublicIP.IPv6 = s.cachedIPv6 + s.mu.Unlock() // Xray status if s.xrayService.IsXrayRunning() { diff --git a/web/web.go b/web/web.go index 2fea2e25..8ef3b7a0 100644 --- a/web/web.go +++ b/web/web.go @@ -276,7 +276,7 @@ func (s *Server) initRouter() (*gin.Engine, error) { go s.wsHub.Run() // Initialize WebSocket controller - s.ws = controller.NewWebSocketController(s.wsHub) + s.ws = controller.NewWebSocketController(s.wsHub, s.api.StatusProvider()) // Register WebSocket route with basePath (g already has basePath prefix) g.GET("/ws", s.ws.HandleWebSocket) diff --git a/web/websocket/hub.go b/web/websocket/hub.go index 8aa5903c..d7896668 100644 --- a/web/websocket/hub.go +++ b/web/websocket/hub.go @@ -364,6 +364,31 @@ func (h *Hub) Unregister(client *Client) { } } +// SendToClient sends a message to a specific client +func (h *Hub) SendToClient(client *Client, messageType MessageType, payload any) { + if h == nil || client == nil || payload == nil { + return + } + + msg := Message{ + Type: messageType, + Payload: payload, + Time: getCurrentTimestamp(), + } + + data, err := json.Marshal(msg) + if err != nil { + logger.Error("Failed to marshal WebSocket message:", err) + return + } + + select { + case client.Send <- data: + default: + logger.Debugf("WebSocket client %s send buffer full when sending initial status", client.ID) + } +} + // Stop gracefully stops the hub and closes all connections func (h *Hub) Stop() { if h == nil { diff --git a/web/websocket/notifier.go b/web/websocket/notifier.go index 74cf61b2..f4be9027 100644 --- a/web/websocket/notifier.go +++ b/web/websocket/notifier.go @@ -24,6 +24,15 @@ func GetHub() *Hub { return wsHub } +// SendStatusToClient sends the current server status directly to a single client. +// Used to push an immediate status snapshot to a newly connected WebSocket client. +func SendStatusToClient(client *Client, status any) { + hub := GetHub() + if hub != nil && client != nil && status != nil { + hub.SendToClient(client, MessageTypeStatus, status) + } +} + // BroadcastStatus broadcasts server status update to all connected clients func BroadcastStatus(status any) { hub := GetHub()