diff --git a/frontend/src/pages/inbounds/ClientRowTable.vue b/frontend/src/pages/inbounds/ClientRowTable.vue index 8a942bff..6ed33119 100644 --- a/frontend/src/pages/inbounds/ClientRowTable.vue +++ b/frontend/src/pages/inbounds/ClientRowTable.vue @@ -33,6 +33,7 @@ const props = defineProps({ isDarkTheme: { type: Boolean, default: false }, pageSize: { type: Number, default: 0 }, totalClientCount: { type: Number, default: 0 }, + statsVersion: { type: Number, default: 0 }, }); const emit = defineEmits([ @@ -63,7 +64,11 @@ watch([clients, () => props.pageSize], () => { }); // === Per-client stats lookup ======================================= +// statsVersion bumps on every ws merge so this computed re-evaluates +// (DBInbound isn't reactive — the in-place stat mutations alone don't +// trigger Vue's tracking). const statsMap = computed(() => { + void props.statsVersion; const m = new Map(); for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs); return m; diff --git a/frontend/src/pages/inbounds/InboundList.vue b/frontend/src/pages/inbounds/InboundList.vue index 3671a520..13e64f03 100644 --- a/frontend/src/pages/inbounds/InboundList.vue +++ b/frontend/src/pages/inbounds/InboundList.vue @@ -50,6 +50,7 @@ const props = defineProps({ // inbound row can render its node name without an extra fetch. nodesById: { type: Map, default: () => new Map() }, hasActiveNode: { type: Boolean, default: false }, + statsVersion: { type: Number, default: 0 }, }); const emit = defineEmits([ @@ -468,6 +469,7 @@ function showQrCodeMenu(dbInbound) { 0 { - if stats, err := j.inboundService.GetActiveClientTraffics(emails); err != nil { - logger.Warning("node traffic sync: get client traffics for websocket failed:", err) - } else if len(stats) > 0 { - clientStats["clients"] = stats - } + if stats, err := j.inboundService.GetAllClientTraffics(); err != nil { + logger.Warning("node traffic sync: get all client traffics for websocket failed:", err) + } else if len(stats) > 0 { + clientStats["clients"] = stats } if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil { logger.Warning("node traffic sync: get inbounds summary for websocket failed:", err) @@ -156,7 +123,7 @@ func (j *NodeTrafficSyncJob) Run() { } } -func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, touched *emailSet) { +func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) { ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout) defer cancel() @@ -179,16 +146,4 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, touche if changed { j.structural.set() } - for _, ib := range snap.Inbounds { - if ib == nil { - continue - } - emails := make([]string, 0, len(ib.ClientStats)) - for _, cs := range ib.ClientStats { - if cs.Email != "" { - emails = append(emails, cs.Email) - } - } - touched.addAll(emails) - } } diff --git a/web/job/xray_traffic_job.go b/web/job/xray_traffic_job.go index b434936f..96539986 100644 --- a/web/job/xray_traffic_job.go +++ b/web/job/xray_traffic_job.go @@ -95,18 +95,16 @@ func (j *XrayTrafficJob) Run() { "lastOnlineMap": lastOnlineMap, }) - // Compact delta payload: per-client absolute counters for clients active - // this cycle, plus inbound-level absolute totals. Frontend applies both - // in-place — typical payload ~10–50KB even for 10k+ client deployments. - // Replaces the old full-inbound-list broadcast that hit WS size limits - // (5–10MB) and forced the frontend into a REST refetch. + // 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 activeEmails := activeEmails(clientTraffics); len(activeEmails) > 0 { - if stats, err := j.inboundService.GetActiveClientTraffics(activeEmails); err != nil { - logger.Warning("get active client traffics for websocket failed:", err) - } else if len(stats) > 0 { - clientStatsPayload["clients"] = stats - } + 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) @@ -126,26 +124,6 @@ func (j *XrayTrafficJob) Run() { } } -// activeEmails returns the set of client emails that had non-zero traffic in -// the current collection window. Idle clients are skipped — no need to push -// their (unchanged) counters to the frontend. -func activeEmails(clientTraffics []*xray.ClientTraffic) []string { - if len(clientTraffics) == 0 { - return nil - } - emails := make([]string, 0, len(clientTraffics)) - for _, ct := range clientTraffics { - if ct == nil || ct.Email == "" { - continue - } - if ct.Up == 0 && ct.Down == 0 { - continue - } - emails = append(emails, ct.Email) - } - return emails -} - func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) { informURL, err := j.settingService.GetExternalTrafficInformURI() if err != nil { diff --git a/web/service/inbound.go b/web/service/inbound.go index e0c80d69..00cb58fe 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -3322,6 +3322,20 @@ func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.Clien return traffics, nil } +// GetAllClientTraffics returns the full set of client_traffics rows so the +// websocket broadcasters can ship a complete snapshot every cycle. The old +// delta-only path (GetActiveClientTraffics on activeEmails) silently dropped +// the per-client section whenever no client moved bytes in the cycle or a +// node sync failed, leaving client rows in the UI stuck at stale numbers. +func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) { + db := database.GetDB() + var traffics []*xray.ClientTraffic + if err := db.Model(xray.ClientTraffic{}).Find(&traffics).Error; err != nil { + return nil, err + } + return traffics, nil +} + type InboundTrafficSummary struct { Id int `json:"id"` Up int64 `json:"up"`