diff --git a/frontend/src/pages/inbounds/useInbounds.ts b/frontend/src/pages/inbounds/useInbounds.ts index 9bf043891..1b1111ad0 100644 --- a/frontend/src/pages/inbounds/useInbounds.ts +++ b/frontend/src/pages/inbounds/useInbounds.ts @@ -225,25 +225,35 @@ export function useInbounds() { const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag); if (dbInbound.enable) { + const statsByEmail = new Map(); + for (const stats of clientStats) { + if (stats.email) statsByEmail.set(stats.email.toLowerCase(), stats); + } for (const client of clients) { if (client.comment && client.email) comments.set(client.email, client.comment); - if (client.enable) { - if (client.email) active.push(client.email); - if (client.email && inboundActive && nodeOnline?.has(client.email)) online.push(client.email); - } else if (client.email) { - deactive.push(client.email); - } - } - for (const stats of clientStats) { - const exhausted = stats.total > 0 && stats.up + stats.down >= stats.total; - const expired = stats.expiryTime > 0 && stats.expiryTime <= now; + if (!client.email) continue; + const stats = statsByEmail.get(client.email.toLowerCase()); + const exhausted = stats != null && stats.total > 0 && stats.up + stats.down >= stats.total; + const expired = stats != null && stats.expiryTime > 0 && stats.expiryTime <= now; + // Depleted wins over disabled (same priority as computeClientsSummary): + // the auto-disable job also flips client.enable off in settings when a + // client ends, so checking enable first would file every ended client + // under "Disabled". if (expired || exhausted) { - depleted.push(stats.email); - } else { + depleted.push(client.email); + continue; + } + if (!client.enable) { + deactive.push(client.email); + continue; + } + active.push(client.email); + if (inboundActive && nodeOnline?.has(client.email)) online.push(client.email); + if (stats) { const expiringSoon = (stats.expiryTime > 0 && stats.expiryTime - now < expireDiffRef.current) || (stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiffRef.current); - if (expiringSoon) expiring.push(stats.email); + if (expiringSoon) expiring.push(client.email); } } } else { diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index b4d050354..e710827b2 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -78,6 +78,10 @@ func (s *InboundService) GetInboundsSlim(userId int) ([]*model.Inbound, error) { } s.annotateFallbackParents(db, inbounds) s.annotateLocalOriginGuid(inbounds) + // Top up stats rows owned by sibling inbounds (multi-attached clients) + // so the list's depleted/expiring badges see every client; the UUID/SubId + // enrichment stays skipped. Must run before slimming strips the settings. + s.backfillClientStats(db, inbounds) for _, ib := range inbounds { ib.Settings = slimSettingsClients(ib.Settings) } diff --git a/internal/web/service/inbound_clients.go b/internal/web/service/inbound_clients.go index 3f6b14e2a..eaf9950b9 100644 --- a/internal/web/service/inbound_clients.go +++ b/internal/web/service/inbound_clients.go @@ -31,6 +31,31 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun if len(inbounds) == 0 { return } + clientsByInbound := s.backfillClientStats(db, inbounds) + for i, inbound := range inbounds { + clients := clientsByInbound[i] + if len(clients) == 0 || len(inbound.ClientStats) == 0 { + continue + } + cMap := make(map[string]model.Client, len(clients)) + for _, c := range clients { + cMap[strings.ToLower(c.Email)] = c + } + for j := range inbound.ClientStats { + email := strings.ToLower(inbound.ClientStats[j].Email) + if c, ok := cMap[email]; ok { + inbound.ClientStats[j].UUID = c.ID + inbound.ClientStats[j].SubId = c.SubID + } + } + } +} + +// backfillClientStats tops up each inbound's preloaded ClientStats with rows +// owned by a sibling inbound: client_traffics is keyed on email, so a client +// attached to several inbounds has one row that only preloads on the inbound +// it was created on. Returns the parsed clients per inbound for reuse. +func (s *InboundService) backfillClientStats(db *gorm.DB, inbounds []*model.Inbound) [][]model.Client { clientsByInbound := make([][]model.Client, len(inbounds)) seenByInbound := make([]map[string]struct{}, len(inbounds)) missing := make(map[string]struct{}) @@ -69,7 +94,7 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun extra = append(extra, page...) } if loadErr != nil { - logger.Warning("enrichClientStats:", loadErr) + logger.Warning("backfillClientStats:", loadErr) } else { byEmail := make(map[string]xray.ClientTraffic, len(extra)) for _, st := range extra { @@ -92,23 +117,7 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun } } } - for i, inbound := range inbounds { - clients := clientsByInbound[i] - if len(clients) == 0 || len(inbound.ClientStats) == 0 { - continue - } - cMap := make(map[string]model.Client, len(clients)) - for _, c := range clients { - cMap[strings.ToLower(c.Email)] = c - } - for j := range inbound.ClientStats { - email := strings.ToLower(inbound.ClientStats[j].Email) - if c, ok := cMap[email]; ok { - inbound.ClientStats[j].UUID = c.ID - inbound.ClientStats[j].SubId = c.SubID - } - } - } + return clientsByInbound } // emailUsedByOtherInbounds reports whether email lives in any inbound other