fix(ui): classify ended clients as depleted, not disabled, on inbounds page

The auto-disable job flips client.enable off in the settings JSON when a
client expires or exhausts its traffic, so the inbounds-page rollup filed
every ended client under the gray Disabled badge (and double-counted it
in Depleted when stats were present). Classify with depleted-first
priority, matching computeClientsSummary and the client info modal.

Also backfill cross-inbound client_traffics rows in GetInboundsSlim:
the row is keyed on email and only preloads on the inbound the client
was created on, so on every other attached inbound the depleted/expiring
checks could never fire.
This commit is contained in:
MHSanaei
2026-06-11 14:05:02 +02:00
parent 9730561f20
commit aeb2217ae5
3 changed files with 54 additions and 31 deletions
+4
View File
@@ -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)
}
+27 -18
View File
@@ -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