fix: resolve persistent Overview loading issue with async IP resolution, mutex protection, WS status push on connect, and frontend retry

Agent-Logs-Url: https://github.com/xAlokyx/3x-ui/sessions/dd7a9d3c-fddb-4521-9fcf-26bb342d408c

Co-authored-by: xAlokyx <234771438+xAlokyx@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-08 16:53:23 +00:00 committed by GitHub
parent 02645f6b58
commit 159457d57b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 167 additions and 48 deletions

View file

@ -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
}

View file

@ -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) {

View file

@ -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)

View file

@ -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) {

View file

@ -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() {

View file

@ -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)

View file

@ -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 {

View file

@ -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()