2551a673c3
Two bugs combined to leave per-client traffic / remained / all-time columns stuck at stale numbers while only the inbound-level row and the online badge refreshed: 1. Backend (xray + node sync traffic jobs) only included the per-client array in the client_stats broadcast when activeEmails / touched was non-empty. Cycles with no client deltas — or any node sync that failed to fetch a snapshot — shipped only the inbound summary, so the frontend had nothing to merge for clients. Replaced both code paths with a single GetAllClientTraffics() snapshot per cycle; the broadcast now always carries the full client list. 2. Frontend mutated dbInbound.clientStats[i] in place. DBInbound is a plain class instance (not wrapped in reactive()), so Vue could not see the field-level changes and ClientRowTable's statsMap computed stayed cached forever. Added a statsVersion tick bumped on every merge and read inside statsMap so the computed re-evaluates and the template pulls fresh up/down/allTime/expiryTime each push. Removed the now-dead emailSet helper from node_traffic_sync_job and the activeEmails filter from xray_traffic_job.
155 lines
5.7 KiB
Go
155 lines
5.7 KiB
Go
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 10k–20k+ 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()
|
||
}
|
||
|
||
// If no frontend client is connected, skip all WebSocket broadcasting
|
||
// routines — including the active-client DB query and JSON marshaling.
|
||
if !websocket.HasClients() {
|
||
return
|
||
}
|
||
|
||
// Online presence + traffic deltas — small payload, always fits in WS.
|
||
// Force non-nil slice/map so JSON marshalling produces [] / {} instead of
|
||
// `null` when everyone is offline. The frontend's traffic handler treats
|
||
// a missing/null onlineClients field as "no update", so without this the
|
||
// "everyone went offline" transition was silently dropped — stale online
|
||
// users lingered in the list and the online filter kept showing them.
|
||
onlineClients := j.inboundService.GetOnlineClients()
|
||
if onlineClients == nil {
|
||
onlineClients = []string{}
|
||
}
|
||
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
||
if err != nil {
|
||
logger.Warning("get clients last online failed:", err)
|
||
}
|
||
if lastOnlineMap == nil {
|
||
lastOnlineMap = make(map[string]int64)
|
||
}
|
||
websocket.BroadcastTraffic(map[string]any{
|
||
"traffics": traffics,
|
||
"clientTraffics": clientTraffics,
|
||
"onlineClients": onlineClients,
|
||
"lastOnlineMap": lastOnlineMap,
|
||
})
|
||
|
||
// Full snapshot every cycle: absolute per-client counters and inbound
|
||
// totals. Frontend overwrites both in place. The previous delta path
|
||
// (activeEmails -> GetActiveClientTraffics) silently omitted the
|
||
// clients array whenever nobody moved bytes in the cycle, leaving the
|
||
// client rows in the UI stuck at stale traffic/remained/all-time.
|
||
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)
|
||
}
|
||
|
||
// Outbounds list is small (one row per outbound, no per-client expansion)
|
||
// so the full snapshot still fits comfortably in WS.
|
||
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)
|
||
}
|
||
}
|