mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-08 22:24:15 +00:00
Merge pull request #11 from xAlokyx/copilot/fix-loading-issue-overview
fix: resolve persistent Overview page stuck on loading spinner
This commit is contained in:
commit
757597d86d
8 changed files with 162 additions and 48 deletions
|
|
@ -56,3 +56,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||||
func (a *APIController) BackuptoTgbot(c *gin.Context) {
|
func (a *APIController) BackuptoTgbot(c *gin.Context) {
|
||||||
a.Tgbot.SendBackupToAdmins()
|
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"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/global"
|
"github.com/mhsanaei/3x-ui/v2/web/global"
|
||||||
|
|
@ -23,6 +24,7 @@ type ServerController struct {
|
||||||
serverService service.ServerService
|
serverService service.ServerService
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
lastStatus *service.Status
|
lastStatus *service.Status
|
||||||
|
|
||||||
lastVersions []string
|
lastVersions []string
|
||||||
|
|
@ -64,17 +66,32 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||||
|
|
||||||
// refreshStatus updates the cached server status and collects CPU history.
|
// refreshStatus updates the cached server status and collects CPU history.
|
||||||
func (a *ServerController) refreshStatus() {
|
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
|
// collect cpu history when status is fresh
|
||||||
if a.lastStatus != nil {
|
if fresh != nil {
|
||||||
a.serverService.AppendCpuSample(time.Now(), a.lastStatus.Cpu)
|
a.serverService.AppendCpuSample(time.Now(), fresh.Cpu)
|
||||||
// Broadcast status update via WebSocket
|
// Broadcast status update via WebSocket
|
||||||
websocket.BroadcastStatus(a.lastStatus)
|
websocket.BroadcastStatus(fresh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startTask initiates background tasks for continuous status monitoring.
|
// 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() {
|
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()
|
webServer := global.GetWebServer()
|
||||||
c := webServer.GetCron()
|
c := webServer.GetCron()
|
||||||
c.AddFunc("@every 2s", func() {
|
c.AddFunc("@every 2s", func() {
|
||||||
|
|
@ -85,7 +102,20 @@ func (a *ServerController) startTask() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// status returns the current server status information.
|
// 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()
|
||||||
|
return a.lastStatus
|
||||||
|
}
|
||||||
|
|
||||||
// getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
|
// getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
|
||||||
func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
|
func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,16 @@ var upgrader = ws.Upgrader{
|
||||||
type WebSocketController struct {
|
type WebSocketController struct {
|
||||||
BaseController
|
BaseController
|
||||||
hub *websocket.Hub
|
hub *websocket.Hub
|
||||||
|
statusProvider func() any // returns current server status (may be nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebSocketController creates a new WebSocket controller
|
// NewWebSocketController creates a new WebSocket controller.
|
||||||
func NewWebSocketController(hub *websocket.Hub) *WebSocketController {
|
// 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{
|
return &WebSocketController{
|
||||||
hub: hub,
|
hub: hub,
|
||||||
|
statusProvider: statusProvider,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,6 +111,14 @@ func (w *WebSocketController) HandleWebSocket(c *gin.Context) {
|
||||||
w.hub.Register(client)
|
w.hub.Register(client)
|
||||||
logger.Debugf("WebSocket client %s registered from %s", clientID, getRemoteIp(c))
|
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
|
// Start goroutines for reading and writing
|
||||||
go w.writePump(client, conn)
|
go w.writePump(client, conn)
|
||||||
go w.readPump(client, conn)
|
go w.readPump(client, conn)
|
||||||
|
|
|
||||||
|
|
@ -904,8 +904,12 @@
|
||||||
async getStatus() {
|
async getStatus() {
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.get('/panel/api/server/status');
|
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);
|
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) {
|
} else if (!this.loadingStates.fetched) {
|
||||||
this.loadingStates.fetched = true;
|
this.loadingStates.fetched = true;
|
||||||
}
|
}
|
||||||
|
|
@ -915,6 +919,7 @@
|
||||||
this.loadingStates.fetched = true;
|
this.loadingStates.fetched = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
setStatus(data) {
|
setStatus(data) {
|
||||||
this.loadingStates.fetched = true;
|
this.loadingStates.fetched = true;
|
||||||
|
|
@ -1129,8 +1134,27 @@
|
||||||
this.ipLimitEnable = msg.obj.ipLimitEnable;
|
this.ipLimitEnable = msg.obj.ipLimitEnable;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial status fetch
|
// Initial status fetch – retry up to 5 times (500 ms apart) if the server
|
||||||
await this.getStatus();
|
// 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(() => {
|
||||||
|
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
|
// Setup WebSocket for real-time updates
|
||||||
if (window.wsClient) {
|
if (window.wsClient) {
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ type ServerService struct {
|
||||||
cachedIPv4 string
|
cachedIPv4 string
|
||||||
cachedIPv6 string
|
cachedIPv6 string
|
||||||
noIPv6 bool
|
noIPv6 bool
|
||||||
|
ipFetchOnce sync.Once
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
lastCPUTimes cpu.TimesStat
|
lastCPUTimes cpu.TimesStat
|
||||||
hasLastCPUSample bool
|
hasLastCPUSample bool
|
||||||
|
|
@ -346,7 +347,10 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
||||||
logger.Warning("get udp connections failed:", err)
|
logger.Warning("get udp connections failed:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IP fetching with caching
|
// 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{
|
showIp4ServiceLists := []string{
|
||||||
"https://api4.ipify.org",
|
"https://api4.ipify.org",
|
||||||
"https://ipv4.icanhazip.com",
|
"https://ipv4.icanhazip.com",
|
||||||
|
|
@ -363,30 +367,34 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
||||||
"https://6.ident.me",
|
"https://6.ident.me",
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.cachedIPv4 == "" {
|
var ipv4, ipv6 string
|
||||||
for _, ip4Service := range showIp4ServiceLists {
|
for _, svc := range showIp4ServiceLists {
|
||||||
s.cachedIPv4 = getPublicIP(ip4Service)
|
ipv4 = getPublicIP(svc)
|
||||||
if s.cachedIPv4 != "N/A" {
|
if ipv4 != "N/A" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
for _, svc := range showIp6ServiceLists {
|
||||||
|
ipv6 = getPublicIP(svc)
|
||||||
if s.cachedIPv6 == "" && !s.noIPv6 {
|
if ipv6 != "N/A" {
|
||||||
for _, ip6Service := range showIp6ServiceLists {
|
|
||||||
s.cachedIPv6 = getPublicIP(ip6Service)
|
|
||||||
if s.cachedIPv6 != "N/A" {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if s.cachedIPv6 == "N/A" {
|
s.mu.Lock()
|
||||||
|
s.cachedIPv4 = ipv4
|
||||||
|
s.cachedIPv6 = ipv6
|
||||||
|
if ipv6 == "N/A" {
|
||||||
s.noIPv6 = true
|
s.noIPv6 = true
|
||||||
}
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
status.PublicIP.IPv4 = s.cachedIPv4
|
status.PublicIP.IPv4 = s.cachedIPv4
|
||||||
status.PublicIP.IPv6 = s.cachedIPv6
|
status.PublicIP.IPv6 = s.cachedIPv6
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
// Xray status
|
// Xray status
|
||||||
if s.xrayService.IsXrayRunning() {
|
if s.xrayService.IsXrayRunning() {
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
go s.wsHub.Run()
|
go s.wsHub.Run()
|
||||||
|
|
||||||
// Initialize WebSocket controller
|
// 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)
|
// Register WebSocket route with basePath (g already has basePath prefix)
|
||||||
g.GET("/ws", s.ws.HandleWebSocket)
|
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
|
// Stop gracefully stops the hub and closes all connections
|
||||||
func (h *Hub) Stop() {
|
func (h *Hub) Stop() {
|
||||||
if h == nil {
|
if h == nil {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,15 @@ func GetHub() *Hub {
|
||||||
return wsHub
|
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
|
// BroadcastStatus broadcasts server status update to all connected clients
|
||||||
func BroadcastStatus(status any) {
|
func BroadcastStatus(status any) {
|
||||||
hub := GetHub()
|
hub := GetHub()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue