3x-ui/web/job/xray_traffic_job.go
MHSanaei 934f9bc230
fix(online): refresh online-clients list even when no WS frontend is connected (#4515)
XrayTrafficJob and NodeTrafficSyncJob both gated the entire
post-traffic-write block behind websocket.HasClients() to skip
expensive broadcasts when no browser is open. The block included the
RefreshOnlineClientsFromMap call that keeps the in-memory
p.onlineClients list current.

Several non-WS consumers read that same list:
- Telegram bot (tgbot.go calls p.GetOnlineClients in 3 places)
- REST GET /panel/api/onlines (returned to API callers)
- Internal alerts that check whether a client is online

When no browser was watching the dashboard, the list went stale and
stayed empty, so the bot reported "nobody online" and the onlines API
returned [] even when xray had active sessions.

Move RefreshOnlineClientsFromMap above the HasClients guard so the
in-memory list is always fresh. Only the actual BroadcastTraffic /
BroadcastClientStats / BroadcastOutbounds calls (and the
GetAllClientTraffics / GetInboundsTrafficSummary work that feeds them)
remain gated by HasClients.

Closes #4515
2026-05-24 23:43:45 +02:00

141 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package job
import (
"encoding/json"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/mhsanaei/3x-ui/v3/web/websocket"
"github.com/mhsanaei/3x-ui/v3/xray"
"github.com/valyala/fasthttp"
)
// XrayTrafficJob collects and processes traffic statistics from Xray, updating the database and optionally informing external APIs.
type XrayTrafficJob struct {
settingService service.SettingService
xrayService service.XrayService
inboundService service.InboundService
outboundService service.OutboundService
}
// NewXrayTrafficJob creates a new traffic collection job instance.
func NewXrayTrafficJob() *XrayTrafficJob {
return new(XrayTrafficJob)
}
// Run collects traffic statistics from Xray, updates the database, and pushes
// real-time updates over WebSocket using compact delta payloads — no REST
// fallback, scales to 10k20k+ clients per inbound.
func (j *XrayTrafficJob) Run() {
if !j.xrayService.IsXrayRunning() {
return
}
traffics, clientTraffics, err := j.xrayService.GetXrayTraffic()
if err != nil {
return
}
needRestart0, clientsDisabled, err := j.inboundService.AddTraffic(traffics, clientTraffics)
if err != nil {
logger.Warning("add inbound traffic failed:", err)
}
err, needRestart1 := j.outboundService.AddTraffic(traffics, clientTraffics)
if err != nil {
logger.Warning("add outbound traffic failed:", err)
}
if clientsDisabled {
restartOnDisable, settingErr := j.settingService.GetRestartXrayOnClientDisable()
if settingErr != nil {
logger.Warning("get RestartXrayOnClientDisable failed:", settingErr)
}
if restartOnDisable {
if err := j.xrayService.RestartXray(true); err != nil {
logger.Warning("restart xray after disabling clients failed:", err)
j.xrayService.SetToNeedRestart()
}
}
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
}
if ExternalTrafficInformEnable, err := j.settingService.GetExternalTrafficInformEnable(); ExternalTrafficInformEnable {
j.informTrafficToExternalAPI(traffics, clientTraffics)
} else if err != nil {
logger.Warning("get ExternalTrafficInformEnable failed:", err)
}
if needRestart0 || needRestart1 {
j.xrayService.SetToNeedRestart()
}
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
if err != nil {
logger.Warning("get clients last online failed:", err)
}
if lastOnlineMap == nil {
lastOnlineMap = make(map[string]int64)
}
j.inboundService.RefreshOnlineClientsFromMap(lastOnlineMap)
if !websocket.HasClients() {
return
}
onlineClients := j.inboundService.GetOnlineClients()
if onlineClients == nil {
onlineClients = []string{}
}
websocket.BroadcastTraffic(map[string]any{
"traffics": traffics,
"clientTraffics": clientTraffics,
"onlineClients": onlineClients,
"lastOnlineMap": lastOnlineMap,
})
clientStatsPayload := map[string]any{}
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
logger.Warning("get all client traffics for websocket failed:", err)
} else if len(stats) > 0 {
clientStatsPayload["clients"] = stats
}
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
logger.Warning("get inbounds traffic summary for websocket failed:", err)
} else if len(inboundSummary) > 0 {
clientStatsPayload["inbounds"] = inboundSummary
}
if len(clientStatsPayload) > 0 {
websocket.BroadcastClientStats(clientStatsPayload)
}
if updatedOutbounds, err := j.outboundService.GetOutboundsTraffic(); err == nil && updatedOutbounds != nil {
websocket.BroadcastOutbounds(updatedOutbounds)
} else if err != nil {
logger.Warning("get all outbounds for websocket failed:", err)
}
}
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
informURL, err := j.settingService.GetExternalTrafficInformURI()
if err != nil {
logger.Warning("get ExternalTrafficInformURI failed:", err)
return
}
informURL, err = service.SanitizePublicHTTPURL(informURL, false)
if err != nil {
logger.Warning("ExternalTrafficInformURI blocked:", err)
return
}
requestBody, err := json.Marshal(map[string]any{"clientTraffics": clientTraffics, "inboundTraffics": inboundTraffics})
if err != nil {
logger.Warning("parse client/inbound traffic failed:", err)
return
}
request := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(request)
request.Header.SetMethod("POST")
request.Header.SetContentType("application/json; charset=UTF-8")
request.SetBody([]byte(requestBody))
request.SetRequestURI(informURL)
response := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(response)
if err := fasthttp.Do(request, response); err != nil {
logger.Warning("POST ExternalTrafficInformURI failed:", err)
}
}