mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
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:
parent
02645f6b58
commit
159457d57b
8 changed files with 167 additions and 48 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue