소스 검색

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.
MHSanaei 22 시간 전
부모
커밋
aeb2217ae5
3개의 변경된 파일53개의 추가작업 그리고 30개의 파일을 삭제
  1. 22 12
      frontend/src/pages/inbounds/useInbounds.ts
  2. 4 0
      internal/web/service/inbound.go
  3. 27 18
      internal/web/service/inbound_clients.go

+ 22 - 12
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<string, { email: string; total: number; up: number; down: number; expiryTime: number }>();
+        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) {
+          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(client.email);
+            continue;
+          }
+          if (!client.enable) {
             deactive.push(client.email);
+            continue;
           }
-        }
-        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 (expired || exhausted) {
-            depleted.push(stats.email);
-          } else {
+          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 {

+ 4 - 0
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)
 	}

+ 27 - 18
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