|
|
@@ -1,13 +1,16 @@
|
|
|
package service
|
|
|
|
|
|
import (
|
|
|
- "slices"
|
|
|
"sort"
|
|
|
"strconv"
|
|
|
"strings"
|
|
|
"time"
|
|
|
|
|
|
+ "github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
|
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
|
+
|
|
|
+ "gorm.io/gorm"
|
|
|
)
|
|
|
|
|
|
// ClientSlim is the row-shape used by the clients page. It drops fields the
|
|
|
@@ -74,33 +77,262 @@ type ClientPageResponse struct {
|
|
|
|
|
|
// ClientsSummary collects per-bucket counts plus the matching email lists so
|
|
|
// the clients page can render the dashboard stat cards and their hover
|
|
|
-// popovers without shipping the full client array.
|
|
|
+// popovers without shipping the full client array. The counters are exact;
|
|
|
+// the lists stop at clientSummaryEmailCap entries and only back the popovers.
|
|
|
type ClientsSummary struct {
|
|
|
- Total int `json:"total"`
|
|
|
- Active int `json:"active"`
|
|
|
- Online []string `json:"online"`
|
|
|
- Depleted []string `json:"depleted"`
|
|
|
- Expiring []string `json:"expiring"`
|
|
|
- Deactive []string `json:"deactive"`
|
|
|
+ Total int `json:"total"`
|
|
|
+ Active int `json:"active"`
|
|
|
+ OnlineCount int `json:"onlineCount"`
|
|
|
+ DepletedCount int `json:"depletedCount"`
|
|
|
+ ExpiringCount int `json:"expiringCount"`
|
|
|
+ DeactiveCount int `json:"deactiveCount"`
|
|
|
+ Online []string `json:"online"`
|
|
|
+ Depleted []string `json:"depleted"`
|
|
|
+ Expiring []string `json:"expiring"`
|
|
|
+ Deactive []string `json:"deactive"`
|
|
|
}
|
|
|
|
|
|
const (
|
|
|
clientPageDefaultSize = 25
|
|
|
clientPageMaxSize = 200
|
|
|
+ // clientSummaryEmailCap bounds each bucket's email list. Shipping every
|
|
|
+ // matching email made the response — and the Zod validation the page runs
|
|
|
+ // over it — grow with the client count on a request that repeats every 5s,
|
|
|
+ // and left the hover popover rendering thousands of rows.
|
|
|
+ clientSummaryEmailCap = 200
|
|
|
+ // sqlNeverSentinel sorts "never expires" / "unlimited quota" clients last,
|
|
|
+ // matching the sentinel the in-memory comparator used.
|
|
|
+ sqlNeverSentinel = "4611686018427387903"
|
|
|
+ // sqlClientEnabled tolerates a NULL enable column, which GORM scans as
|
|
|
+ // false: without the COALESCE such a row would match neither the enabled
|
|
|
+ // nor the disabled branch of any predicate.
|
|
|
+ sqlClientEnabled = "COALESCE(c.enable, FALSE)"
|
|
|
)
|
|
|
|
|
|
-// ListPaged loads every client (with traffic + attachments) into memory,
|
|
|
-// applies the requested filter / search / protocol predicates, sorts, and
|
|
|
-// returns the requested page along with total and filtered counts. The DB
|
|
|
-// query itself is unchanged from List(); the win is that the response
|
|
|
-// only carries 25-ish slim rows over the wire instead of all 2000 full
|
|
|
-// records, which on real panels was the dominant cost.
|
|
|
-func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
|
|
|
- all, err := s.List()
|
|
|
- if err != nil {
|
|
|
- return nil, err
|
|
|
+const clientSearchCond = `(LOWER(c.email) LIKE ? ESCAPE '\'
|
|
|
+ OR LOWER(COALESCE(c.sub_id, '')) LIKE ? ESCAPE '\'
|
|
|
+ OR LOWER(COALESCE(c.comment, '')) LIKE ? ESCAPE '\'
|
|
|
+ OR LOWER(COALESCE(c.uuid, '')) LIKE ? ESCAPE '\'
|
|
|
+ OR LOWER(COALESCE(c.password, '')) LIKE ? ESCAPE '\'
|
|
|
+ OR LOWER(COALESCE(c.auth, '')) LIKE ? ESCAPE '\'
|
|
|
+ OR (COALESCE(c.tg_id, 0) <> 0 AND CAST(c.tg_id AS TEXT) LIKE ? ESCAPE '\'))`
|
|
|
+
|
|
|
+// clientQuery builds the statements behind the clients page: a clients row
|
|
|
+// joined to its traffic counters, plus the expressions every bucket predicate
|
|
|
+// shares. Filtering, sorting, paging and the summary all run in the database.
|
|
|
+// Loading every client (with attachments and traffic) into Go and doing it in
|
|
|
+// memory cost ~200ms per request at 20k clients on a page that polls every
|
|
|
+// 5 seconds, which is what made the table feel stuck on large panels.
|
|
|
+type clientQuery struct {
|
|
|
+ db *gorm.DB
|
|
|
+ joins []clientQueryJoin
|
|
|
+ usedExpr string
|
|
|
+ nowMs int64
|
|
|
+ expireDiffMs int64
|
|
|
+ trafficDiffBytes int64
|
|
|
+}
|
|
|
+
|
|
|
+type clientQueryJoin struct {
|
|
|
+ sql string
|
|
|
+ args []any
|
|
|
+}
|
|
|
+
|
|
|
+func newClientQuery(db *gorm.DB, nowMs, expireDiffMs, trafficDiffBytes int64) clientQuery {
|
|
|
+ q := clientQuery{
|
|
|
+ db: db,
|
|
|
+ nowMs: nowMs,
|
|
|
+ expireDiffMs: expireDiffMs,
|
|
|
+ trafficDiffBytes: trafficDiffBytes,
|
|
|
+ joins: []clientQueryJoin{{sql: "LEFT JOIN client_traffics ct ON ct.email = c.email"}},
|
|
|
+ usedExpr: "(COALESCE(ct.up, 0) + COALESCE(ct.down, 0))",
|
|
|
+ }
|
|
|
+ freshSince := globalTrafficFreshSince()
|
|
|
+ var probe int64
|
|
|
+ err := db.Model(&model.ClientGlobalTraffic{}).
|
|
|
+ Where("updated_at >= ?", freshSince).
|
|
|
+ Limit(1).Count(&probe).Error
|
|
|
+ if err != nil || probe == 0 {
|
|
|
+ return q
|
|
|
+ }
|
|
|
+ // A master still pushes cross-panel usage here, so the predicates have to
|
|
|
+ // see the same raised counters overlayGlobalTraffic applies on read.
|
|
|
+ q.joins = append(q.joins, clientQueryJoin{
|
|
|
+ sql: "LEFT JOIN (SELECT email, MAX(up) AS up, MAX(down) AS down FROM client_global_traffics" +
|
|
|
+ " WHERE updated_at >= ? GROUP BY email) g ON g.email = c.email",
|
|
|
+ args: []any{freshSince},
|
|
|
+ })
|
|
|
+ q.usedExpr = "(CASE WHEN COALESCE(g.up, 0) > COALESCE(ct.up, 0) THEN COALESCE(g.up, 0) ELSE COALESCE(ct.up, 0) END" +
|
|
|
+ " + CASE WHEN COALESCE(g.down, 0) > COALESCE(ct.down, 0) THEN COALESCE(g.down, 0) ELSE COALESCE(ct.down, 0) END)"
|
|
|
+ return q
|
|
|
+}
|
|
|
+
|
|
|
+func (q clientQuery) from() *gorm.DB {
|
|
|
+ tx := q.db.Table("clients AS c")
|
|
|
+ for _, j := range q.joins {
|
|
|
+ tx = tx.Joins(j.sql, j.args...)
|
|
|
+ }
|
|
|
+ return tx
|
|
|
+}
|
|
|
+
|
|
|
+func (q clientQuery) depletedExpr() string {
|
|
|
+ return "((c.total_gb > 0 AND " + q.usedExpr + " >= c.total_gb)" +
|
|
|
+ " OR (c.expiry_time > 0 AND c.expiry_time <= " + sqlInt(q.nowMs) + "))"
|
|
|
+}
|
|
|
+
|
|
|
+func (q clientQuery) nearDepletionExpr() string {
|
|
|
+ return "((c.expiry_time > 0 AND c.expiry_time - " + sqlInt(q.nowMs) + " < " + sqlInt(q.expireDiffMs) + ")" +
|
|
|
+ " OR (c.total_gb > 0 AND c.total_gb - " + q.usedExpr + " < " + sqlInt(q.trafficDiffBytes) + "))"
|
|
|
+}
|
|
|
+
|
|
|
+func (q clientQuery) expiringExpr() string {
|
|
|
+ return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND " + q.nearDepletionExpr() + ")"
|
|
|
+}
|
|
|
+
|
|
|
+func (q clientQuery) activeExpr() string {
|
|
|
+ return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")"
|
|
|
+}
|
|
|
+
|
|
|
+// summaryDeactiveExpr is narrower than the "deactive" bucket filter: a disabled
|
|
|
+// client that also ran out counts once, under depleted, so the stat cards add
|
|
|
+// up to the client total.
|
|
|
+func (q clientQuery) summaryDeactiveExpr() string {
|
|
|
+ return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")"
|
|
|
+}
|
|
|
+
|
|
|
+// applyParams narrows tx by every predicate the clients page sends. Matching is
|
|
|
+// OR within a field and AND across fields, mirroring the query-param contract.
|
|
|
+// The second return says whether anything narrowed the set, so an unfiltered
|
|
|
+// request can reuse the total count instead of scanning for it again.
|
|
|
+func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines []string) (*gorm.DB, bool) {
|
|
|
+ narrowed := false
|
|
|
+ where := func(cond string, args ...any) {
|
|
|
+ narrowed = true
|
|
|
+ tx = tx.Where(cond, args...)
|
|
|
+ }
|
|
|
+
|
|
|
+ if needle := strings.ToLower(strings.TrimSpace(params.Search)); needle != "" {
|
|
|
+ pattern := "%" + escapeLikeLiteral(needle) + "%"
|
|
|
+ where(clientSearchCond, pattern, pattern, pattern, pattern, pattern, pattern, pattern)
|
|
|
+ }
|
|
|
+ if protocols := parseCSVStrings(params.Protocol); len(protocols) > 0 {
|
|
|
+ where("EXISTS (SELECT 1 FROM client_inbounds ci JOIN inbounds ib ON ib.id = ci.inbound_id"+
|
|
|
+ " WHERE ci.client_id = c.id AND LOWER(ib.protocol) IN ?)", protocols)
|
|
|
}
|
|
|
- total := len(all)
|
|
|
+ if inboundIds := parseCSVInts(params.Inbound); len(inboundIds) > 0 {
|
|
|
+ where("EXISTS (SELECT 1 FROM client_inbounds ci WHERE ci.client_id = c.id AND ci.inbound_id IN ?)", inboundIds)
|
|
|
+ }
|
|
|
+ if buckets := parseCSVStrings(params.Filter); len(buckets) > 0 {
|
|
|
+ cond, args := q.bucketCond(buckets, onlines)
|
|
|
+ where(cond, args...)
|
|
|
+ }
|
|
|
+ if params.ExpiryFrom > 0 || params.ExpiryTo > 0 {
|
|
|
+ // 0 means "never expires" and a negative value is the delayed-start
|
|
|
+ // sentinel; both sit outside any bounded range.
|
|
|
+ where("c.expiry_time > 0")
|
|
|
+ if params.ExpiryFrom > 0 {
|
|
|
+ where("c.expiry_time >= ?", params.ExpiryFrom)
|
|
|
+ }
|
|
|
+ if params.ExpiryTo > 0 {
|
|
|
+ where("c.expiry_time <= ?", params.ExpiryTo)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if params.UsageFrom > 0 {
|
|
|
+ where(q.usedExpr+" >= ?", params.UsageFrom)
|
|
|
+ }
|
|
|
+ if params.UsageTo > 0 {
|
|
|
+ where(q.usedExpr+" <= ?", params.UsageTo)
|
|
|
+ }
|
|
|
+ switch strings.ToLower(strings.TrimSpace(params.AutoRenew)) {
|
|
|
+ case "on":
|
|
|
+ where("COALESCE(c.reset, 0) > 0")
|
|
|
+ case "off":
|
|
|
+ where("COALESCE(c.reset, 0) <= 0")
|
|
|
+ }
|
|
|
+ switch strings.ToLower(strings.TrimSpace(params.HasTgID)) {
|
|
|
+ case "yes":
|
|
|
+ where("COALESCE(c.tg_id, 0) <> 0")
|
|
|
+ case "no":
|
|
|
+ where("COALESCE(c.tg_id, 0) = 0")
|
|
|
+ }
|
|
|
+ switch strings.ToLower(strings.TrimSpace(params.HasComment)) {
|
|
|
+ case "yes":
|
|
|
+ where("TRIM(COALESCE(c.comment, '')) <> ''")
|
|
|
+ case "no":
|
|
|
+ where("TRIM(COALESCE(c.comment, '')) = ''")
|
|
|
+ }
|
|
|
+ if groups := parseCSVStrings(params.Group); len(groups) > 0 {
|
|
|
+ where("LOWER(TRIM(COALESCE(c.group_name, ''))) IN ?", groups)
|
|
|
+ }
|
|
|
+ return tx, narrowed
|
|
|
+}
|
|
|
+
|
|
|
+func (q clientQuery) bucketCond(buckets, onlines []string) (string, []any) {
|
|
|
+ conds := make([]string, 0, len(buckets))
|
|
|
+ args := make([]any, 0, len(buckets))
|
|
|
+ for _, b := range buckets {
|
|
|
+ switch b {
|
|
|
+ case "active":
|
|
|
+ conds = append(conds, "("+sqlClientEnabled+" AND NOT "+q.depletedExpr()+")")
|
|
|
+ case "deactive":
|
|
|
+ conds = append(conds, "(NOT "+sqlClientEnabled+")")
|
|
|
+ case "depleted":
|
|
|
+ conds = append(conds, q.depletedExpr())
|
|
|
+ case "expiring":
|
|
|
+ conds = append(conds, q.expiringExpr())
|
|
|
+ case "online":
|
|
|
+ cond, inArgs := emailInCond("c.email", onlines)
|
|
|
+ conds = append(conds, "("+sqlClientEnabled+" AND "+cond+")")
|
|
|
+ args = append(args, inArgs...)
|
|
|
+ default:
|
|
|
+ // An unrecognised bucket name matched every client before the
|
|
|
+ // predicates moved into SQL; keep that so a stale saved filter
|
|
|
+ // cannot silently empty the table.
|
|
|
+ conds = append(conds, "(1 = 1)")
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return "(" + strings.Join(conds, " OR ") + ")", args
|
|
|
+}
|
|
|
+
|
|
|
+func (q clientQuery) applyOrder(tx *gorm.DB, sortKey, order string) *gorm.DB {
|
|
|
+ dir := " ASC"
|
|
|
+ if order == "descend" {
|
|
|
+ dir = " DESC"
|
|
|
+ }
|
|
|
+ // createdAt / updatedAt / lastOnline broke ties on the client id inside the
|
|
|
+ // comparator, so reversing the sort reversed the tiebreak with it. The
|
|
|
+ // other keys leaned on a stable sort over an id-ordered slice instead.
|
|
|
+ tieDir := " ASC"
|
|
|
+ var expr string
|
|
|
+ switch sortKey {
|
|
|
+ case "enable":
|
|
|
+ expr = sqlClientEnabled
|
|
|
+ case "email":
|
|
|
+ expr = "LOWER(c.email)"
|
|
|
+ case "inboundIds":
|
|
|
+ expr = "(SELECT COUNT(*) FROM client_inbounds ci WHERE ci.client_id = c.id)"
|
|
|
+ case "traffic":
|
|
|
+ expr = q.usedExpr
|
|
|
+ case "remaining":
|
|
|
+ expr = "CASE WHEN c.total_gb > 0 THEN c.total_gb - " + q.usedExpr + " ELSE " + sqlNeverSentinel + " END"
|
|
|
+ case "expiryTime":
|
|
|
+ expr = "CASE WHEN c.expiry_time > 0 THEN c.expiry_time ELSE " + sqlNeverSentinel + " END"
|
|
|
+ case "createdAt":
|
|
|
+ expr, tieDir = "c.created_at", dir
|
|
|
+ case "updatedAt":
|
|
|
+ expr, tieDir = "c.updated_at", dir
|
|
|
+ case "lastOnline":
|
|
|
+ expr, tieDir = "COALESCE(ct.last_online, 0)", dir
|
|
|
+ default:
|
|
|
+ return tx.Order("c.id ASC")
|
|
|
+ }
|
|
|
+ return tx.Order(expr + dir + ", c.id" + tieDir)
|
|
|
+}
|
|
|
+
|
|
|
+// ListPaged returns one page of clients together with the counts the clients
|
|
|
+// page header needs. Every predicate runs in SQL, so the cost tracks the page
|
|
|
+// size rather than the number of clients on the panel.
|
|
|
+func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
|
|
|
+ db := database.GetDB()
|
|
|
|
|
|
pageSize := params.PageSize
|
|
|
if pageSize <= 0 {
|
|
|
@@ -114,27 +346,6 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
|
|
page = 1
|
|
|
}
|
|
|
|
|
|
- protocols := parseCSVStrings(params.Protocol)
|
|
|
- inboundIDs := parseCSVInts(params.Inbound)
|
|
|
- buckets := parseCSVStrings(params.Filter)
|
|
|
-
|
|
|
- var protocolByInbound map[int]string
|
|
|
- if len(protocols) > 0 {
|
|
|
- inbounds, err := inboundSvc.GetAllInbounds()
|
|
|
- if err == nil {
|
|
|
- protocolByInbound = make(map[int]string, len(inbounds))
|
|
|
- for _, ib := range inbounds {
|
|
|
- protocolByInbound[ib.Id] = string(ib.Protocol)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- onlines := inboundSvc.GetOnlineClients()
|
|
|
- onlineSet := make(map[string]struct{}, len(onlines))
|
|
|
- for _, e := range onlines {
|
|
|
- onlineSet[e] = struct{}{}
|
|
|
- }
|
|
|
-
|
|
|
var expireDiffMs, trafficDiffBytes int64
|
|
|
if settingSvc != nil {
|
|
|
if v, err := settingSvc.GetExpireDiff(); err == nil {
|
|
|
@@ -145,77 +356,44 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- nowMs := time.Now().UnixMilli()
|
|
|
- summary := buildClientsSummary(all, onlineSet, nowMs, expireDiffMs, trafficDiffBytes)
|
|
|
-
|
|
|
- needle := strings.ToLower(strings.TrimSpace(params.Search))
|
|
|
+ onlines := inboundSvc.GetOnlineClients()
|
|
|
+ q := newClientQuery(db, time.Now().UnixMilli(), expireDiffMs, trafficDiffBytes)
|
|
|
|
|
|
- filtered := make([]ClientWithAttachments, 0, len(all))
|
|
|
- for _, c := range all {
|
|
|
- if needle != "" && !clientMatchesSearch(c, needle) {
|
|
|
- continue
|
|
|
- }
|
|
|
- if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) {
|
|
|
- continue
|
|
|
- }
|
|
|
- if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) {
|
|
|
- continue
|
|
|
- }
|
|
|
- if len(buckets) > 0 && !clientMatchesAnyBucket(c, buckets, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
|
|
- continue
|
|
|
- }
|
|
|
- if !clientMatchesExpiryRange(c, params.ExpiryFrom, params.ExpiryTo) {
|
|
|
- continue
|
|
|
- }
|
|
|
- if !clientMatchesUsageRange(c, params.UsageFrom, params.UsageTo) {
|
|
|
- continue
|
|
|
- }
|
|
|
- if !clientMatchesAutoRenew(c, params.AutoRenew) {
|
|
|
- continue
|
|
|
- }
|
|
|
- if !clientMatchesHasTgID(c, params.HasTgID) {
|
|
|
- continue
|
|
|
- }
|
|
|
- if !clientMatchesHasComment(c, params.HasComment) {
|
|
|
- continue
|
|
|
- }
|
|
|
- if !clientMatchesAnyGroup(c, params.Group) {
|
|
|
- continue
|
|
|
- }
|
|
|
- filtered = append(filtered, c)
|
|
|
+ var total int64
|
|
|
+ if err := db.Model(&model.ClientRecord{}).Count(&total).Error; err != nil {
|
|
|
+ return nil, err
|
|
|
}
|
|
|
|
|
|
- sortClients(filtered, params.Sort, params.Order)
|
|
|
-
|
|
|
- filteredCount := len(filtered)
|
|
|
- start := (page - 1) * pageSize
|
|
|
- end := start + pageSize
|
|
|
- if start > filteredCount {
|
|
|
- start = filteredCount
|
|
|
- }
|
|
|
- if end > filteredCount {
|
|
|
- end = filteredCount
|
|
|
+ summary, err := q.summary(onlines, int(total))
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
}
|
|
|
- pageRows := filtered[start:end]
|
|
|
|
|
|
- items := make([]ClientSlim, 0, len(pageRows))
|
|
|
- for _, c := range pageRows {
|
|
|
- items = append(items, toClientSlim(c))
|
|
|
+ filtered := total
|
|
|
+ if scoped, narrowed := q.applyParams(q.from(), params, onlines); narrowed {
|
|
|
+ if err := scoped.Count(&filtered).Error; err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- groupRows, gErr := s.ListGroups()
|
|
|
- if gErr != nil {
|
|
|
- return nil, gErr
|
|
|
+ items := []ClientSlim{}
|
|
|
+ offset := (page - 1) * pageSize
|
|
|
+ if int64(offset) < filtered {
|
|
|
+ items, err = q.pageRows(params, onlines, offset, pageSize)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
}
|
|
|
- groups := make([]string, 0, len(groupRows))
|
|
|
- for _, g := range groupRows {
|
|
|
- groups = append(groups, g.Name)
|
|
|
+
|
|
|
+ groups, err := s.listGroupNames()
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
}
|
|
|
|
|
|
return &ClientPageResponse{
|
|
|
Items: items,
|
|
|
- Total: total,
|
|
|
- Filtered: filteredCount,
|
|
|
+ Total: int(total),
|
|
|
+ Filtered: int(filtered),
|
|
|
Page: page,
|
|
|
PageSize: pageSize,
|
|
|
Summary: summary,
|
|
|
@@ -223,77 +401,229 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
|
|
}, nil
|
|
|
}
|
|
|
|
|
|
-func buildClientsSummary(all []ClientWithAttachments, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) ClientsSummary {
|
|
|
+// pageRows resolves the requested page to client ids, then loads the records,
|
|
|
+// attachments and traffic for those ids only. A page never exceeds
|
|
|
+// clientPageMaxSize rows, which stays under sqlInChunk, so the follow-up IN
|
|
|
+// lists need no chunking.
|
|
|
+func (q clientQuery) pageRows(params ClientPageParams, onlines []string, offset, limit int) ([]ClientSlim, error) {
|
|
|
+ tx, _ := q.applyParams(q.from(), params, onlines)
|
|
|
+ var ids []int
|
|
|
+ if err := q.applyOrder(tx, params.Sort, params.Order).
|
|
|
+ Offset(offset).Limit(limit).
|
|
|
+ Pluck("c.id", &ids).Error; err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ if len(ids) == 0 {
|
|
|
+ return []ClientSlim{}, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ var records []model.ClientRecord
|
|
|
+ if err := q.db.Where("id IN ?", ids).Find(&records).Error; err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ byId := make(map[int]*model.ClientRecord, len(records))
|
|
|
+ emails := make([]string, 0, len(records))
|
|
|
+ for i := range records {
|
|
|
+ byId[records[i].Id] = &records[i]
|
|
|
+ if records[i].Email != "" {
|
|
|
+ emails = append(emails, records[i].Email)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var links []model.ClientInbound
|
|
|
+ if err := q.db.Where("client_id IN ?", ids).Order("inbound_id ASC").Find(&links).Error; err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ attachments := make(map[int][]int, len(ids))
|
|
|
+ for _, l := range links {
|
|
|
+ attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
|
|
|
+ }
|
|
|
+
|
|
|
+ trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
|
|
|
+ if len(emails) > 0 {
|
|
|
+ var stats []xray.ClientTraffic
|
|
|
+ if err := q.db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ overlayGlobalTrafficValues(q.db, stats)
|
|
|
+ for i := range stats {
|
|
|
+ trafficByEmail[stats[i].Email] = &stats[i]
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ items := make([]ClientSlim, 0, len(ids))
|
|
|
+ for _, id := range ids {
|
|
|
+ rec := byId[id]
|
|
|
+ if rec == nil {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ items = append(items, ClientSlim{
|
|
|
+ Email: rec.Email,
|
|
|
+ SubID: rec.SubID,
|
|
|
+ Enable: rec.Enable,
|
|
|
+ TotalGB: rec.TotalGB,
|
|
|
+ ExpiryTime: rec.ExpiryTime,
|
|
|
+ LimitIP: rec.LimitIP,
|
|
|
+ Reset: rec.Reset,
|
|
|
+ Group: rec.Group,
|
|
|
+ Comment: rec.Comment,
|
|
|
+ InboundIds: attachments[rec.Id],
|
|
|
+ Traffic: trafficByEmail[rec.Email],
|
|
|
+ CreatedAt: rec.CreatedAt,
|
|
|
+ UpdatedAt: rec.UpdatedAt,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return items, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error) {
|
|
|
s := ClientsSummary{
|
|
|
- Total: len(all),
|
|
|
+ Total: total,
|
|
|
Online: []string{},
|
|
|
Depleted: []string{},
|
|
|
Expiring: []string{},
|
|
|
Deactive: []string{},
|
|
|
}
|
|
|
- for _, c := range all {
|
|
|
- used := int64(0)
|
|
|
- if c.Traffic != nil {
|
|
|
- used = c.Traffic.Up + c.Traffic.Down
|
|
|
- }
|
|
|
- exhausted := c.TotalGB > 0 && used >= c.TotalGB
|
|
|
- expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
|
|
|
- if c.Enable {
|
|
|
- if _, ok := onlineSet[c.Email]; ok {
|
|
|
- s.Online = append(s.Online, c.Email)
|
|
|
- }
|
|
|
- }
|
|
|
- if exhausted || expired {
|
|
|
- s.Depleted = append(s.Depleted, c.Email)
|
|
|
+
|
|
|
+ var counts struct {
|
|
|
+ Active int64
|
|
|
+ Depleted int64
|
|
|
+ Expiring int64
|
|
|
+ Deactive int64
|
|
|
+ }
|
|
|
+ // SUM over an empty table yields NULL, which not every driver scans into an
|
|
|
+ // int; COALESCE keeps a panel with no clients from erroring out.
|
|
|
+ if err := q.from().Select(
|
|
|
+ "COALESCE(SUM(CASE WHEN " + q.activeExpr() + " THEN 1 ELSE 0 END), 0) AS active," +
|
|
|
+ " COALESCE(SUM(CASE WHEN " + q.depletedExpr() + " THEN 1 ELSE 0 END), 0) AS depleted," +
|
|
|
+ " COALESCE(SUM(CASE WHEN " + q.expiringExpr() + " THEN 1 ELSE 0 END), 0) AS expiring," +
|
|
|
+ " COALESCE(SUM(CASE WHEN " + q.summaryDeactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
|
|
|
+ ).Scan(&counts).Error; err != nil {
|
|
|
+ return s, err
|
|
|
+ }
|
|
|
+ s.Active = int(counts.Active)
|
|
|
+ s.DepletedCount = int(counts.Depleted)
|
|
|
+ s.ExpiringCount = int(counts.Expiring)
|
|
|
+ s.DeactiveCount = int(counts.Deactive)
|
|
|
+
|
|
|
+ buckets := []struct {
|
|
|
+ cond string
|
|
|
+ count int
|
|
|
+ out *[]string
|
|
|
+ }{
|
|
|
+ {q.depletedExpr(), s.DepletedCount, &s.Depleted},
|
|
|
+ {q.expiringExpr(), s.ExpiringCount, &s.Expiring},
|
|
|
+ {q.summaryDeactiveExpr(), s.DeactiveCount, &s.Deactive},
|
|
|
+ }
|
|
|
+ for _, b := range buckets {
|
|
|
+ // The counter already says the bucket is empty, so skip the scan that
|
|
|
+ // would look for emails it cannot find.
|
|
|
+ if b.count == 0 {
|
|
|
continue
|
|
|
}
|
|
|
- if !c.Enable {
|
|
|
- s.Deactive = append(s.Deactive, c.Email)
|
|
|
- continue
|
|
|
+ var emails []string
|
|
|
+ if err := q.from().Where(b.cond).
|
|
|
+ Order("c.id ASC").Limit(clientSummaryEmailCap).
|
|
|
+ Pluck("c.email", &emails).Error; err != nil {
|
|
|
+ return s, err
|
|
|
}
|
|
|
- nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
|
|
|
- nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
|
|
|
- if nearExpiry || nearLimit {
|
|
|
- s.Expiring = append(s.Expiring, c.Email)
|
|
|
- } else {
|
|
|
- s.Active++
|
|
|
+ if len(emails) > 0 {
|
|
|
+ *b.out = emails
|
|
|
}
|
|
|
}
|
|
|
- return s
|
|
|
-}
|
|
|
|
|
|
-func toClientSlim(c ClientWithAttachments) ClientSlim {
|
|
|
- return ClientSlim{
|
|
|
- Email: c.Email,
|
|
|
- SubID: c.SubID,
|
|
|
- Enable: c.Enable,
|
|
|
- TotalGB: c.TotalGB,
|
|
|
- ExpiryTime: c.ExpiryTime,
|
|
|
- LimitIP: c.LimitIP,
|
|
|
- Reset: c.Reset,
|
|
|
- Group: c.Group,
|
|
|
- Comment: c.Comment,
|
|
|
- InboundIds: c.InboundIds,
|
|
|
- Traffic: c.Traffic,
|
|
|
- CreatedAt: c.CreatedAt,
|
|
|
- UpdatedAt: c.UpdatedAt,
|
|
|
+ online, onlineCount, err := q.onlineEmails(onlines)
|
|
|
+ if err != nil {
|
|
|
+ return s, err
|
|
|
}
|
|
|
+ s.Online = online
|
|
|
+ s.OnlineCount = onlineCount
|
|
|
+ return s, nil
|
|
|
+}
|
|
|
+
|
|
|
+// onlineEmails intersects the emails xray reports as connected with the enabled
|
|
|
+// clients this panel stores. The online set lives in memory and is bounded by
|
|
|
+// live connections, so it drives the query rather than a scan of every client.
|
|
|
+func (q clientQuery) onlineEmails(onlines []string) ([]string, int, error) {
|
|
|
+ matched := []string{}
|
|
|
+ count := 0
|
|
|
+ for _, batch := range chunkStrings(onlines, sqlInChunk) {
|
|
|
+ var page []string
|
|
|
+ if err := q.db.Model(&model.ClientRecord{}).
|
|
|
+ Where("COALESCE(enable, FALSE) = TRUE AND email IN ?", batch).
|
|
|
+ Order("id ASC").
|
|
|
+ Pluck("email", &page).Error; err != nil {
|
|
|
+ return nil, 0, err
|
|
|
+ }
|
|
|
+ count += len(page)
|
|
|
+ if room := clientSummaryEmailCap - len(matched); room > 0 {
|
|
|
+ matched = append(matched, page[:min(room, len(page))]...)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return matched, count, nil
|
|
|
}
|
|
|
|
|
|
-func clientMatchesSearch(c ClientWithAttachments, needle string) bool {
|
|
|
- if needle == "" {
|
|
|
- return true
|
|
|
+// listGroupNames returns the group names the clients page offers as filters:
|
|
|
+// the stored groups plus any name a client still carries. ListGroups also sums
|
|
|
+// per-client traffic per group, which this page never reads and which costs a
|
|
|
+// full join over client_traffics on every poll.
|
|
|
+func (s *ClientService) listGroupNames() ([]string, error) {
|
|
|
+ db := database.GetDB()
|
|
|
+ var stored []string
|
|
|
+ if err := db.Model(&model.ClientGroup{}).Pluck("name", &stored).Error; err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ var used []string
|
|
|
+ if err := db.Model(&model.ClientRecord{}).
|
|
|
+ Where("group_name <> ''").
|
|
|
+ Distinct().
|
|
|
+ Pluck("group_name", &used).Error; err != nil {
|
|
|
+ return nil, err
|
|
|
}
|
|
|
- candidates := [...]string{c.Email, c.SubID, c.Comment, c.UUID, c.Password, c.Auth}
|
|
|
- for _, v := range candidates {
|
|
|
- if v != "" && strings.Contains(strings.ToLower(v), needle) {
|
|
|
- return true
|
|
|
+ seen := make(map[string]struct{}, len(stored)+len(used))
|
|
|
+ out := make([]string, 0, len(stored)+len(used))
|
|
|
+ for _, list := range [][]string{stored, used} {
|
|
|
+ for _, name := range list {
|
|
|
+ if name == "" {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if _, dup := seen[name]; dup {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ seen[name] = struct{}{}
|
|
|
+ out = append(out, name)
|
|
|
}
|
|
|
}
|
|
|
- if c.TgID != 0 && strings.Contains(strconv.FormatInt(c.TgID, 10), needle) {
|
|
|
- return true
|
|
|
+ sort.Slice(out, func(i, j int) bool {
|
|
|
+ return strings.ToLower(out[i]) < strings.ToLower(out[j])
|
|
|
+ })
|
|
|
+ return out, nil
|
|
|
+}
|
|
|
+
|
|
|
+func sqlInt(v int64) string {
|
|
|
+ return strconv.FormatInt(v, 10)
|
|
|
+}
|
|
|
+
|
|
|
+// escapeLikeLiteral neutralises LIKE wildcards so searching for "a_b" keeps
|
|
|
+// matching literally, the way strings.Contains did.
|
|
|
+func escapeLikeLiteral(s string) string {
|
|
|
+ return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
|
|
+}
|
|
|
+
|
|
|
+// emailInCond renders an IN over a possibly large email set, split so no single
|
|
|
+// IN list outgrows the drivers' bind-parameter ceiling.
|
|
|
+func emailInCond(column string, emails []string) (string, []any) {
|
|
|
+ if len(emails) == 0 {
|
|
|
+ return "1 = 0", nil
|
|
|
+ }
|
|
|
+ chunks := chunkStrings(emails, sqlInChunk)
|
|
|
+ parts := make([]string, 0, len(chunks))
|
|
|
+ args := make([]any, 0, len(chunks))
|
|
|
+ for _, chunk := range chunks {
|
|
|
+ parts = append(parts, column+" IN ?")
|
|
|
+ args = append(args, chunk)
|
|
|
}
|
|
|
- return false
|
|
|
+ return "(" + strings.Join(parts, " OR ") + ")", args
|
|
|
}
|
|
|
|
|
|
// parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
|
|
|
@@ -339,246 +669,3 @@ func parseCSVInts(raw string) []int {
|
|
|
}
|
|
|
return out
|
|
|
}
|
|
|
-
|
|
|
-func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool {
|
|
|
- for _, id := range c.InboundIds {
|
|
|
- p := byInbound[id]
|
|
|
- if p == "" {
|
|
|
- continue
|
|
|
- }
|
|
|
- if slices.Contains(protocols, strings.ToLower(p)) {
|
|
|
- return true
|
|
|
- }
|
|
|
- }
|
|
|
- return false
|
|
|
-}
|
|
|
-
|
|
|
-func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool {
|
|
|
- for _, id := range c.InboundIds {
|
|
|
- if slices.Contains(inboundIds, id) {
|
|
|
- return true
|
|
|
- }
|
|
|
- }
|
|
|
- return false
|
|
|
-}
|
|
|
-
|
|
|
-func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
|
|
|
- for _, b := range buckets {
|
|
|
- if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
|
|
- return true
|
|
|
- }
|
|
|
- }
|
|
|
- return false
|
|
|
-}
|
|
|
-
|
|
|
-func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool {
|
|
|
- if fromMs <= 0 && toMs <= 0 {
|
|
|
- return true
|
|
|
- }
|
|
|
- // expiryTime of 0 means "never expires"; treat it as outside any bounded
|
|
|
- // range so users filtering by date see only clients with concrete expiries.
|
|
|
- if c.ExpiryTime == 0 {
|
|
|
- return false
|
|
|
- }
|
|
|
- // Negative expiry is the "delayed start" sentinel; same treatment as never.
|
|
|
- if c.ExpiryTime < 0 {
|
|
|
- return false
|
|
|
- }
|
|
|
- if fromMs > 0 && c.ExpiryTime < fromMs {
|
|
|
- return false
|
|
|
- }
|
|
|
- if toMs > 0 && c.ExpiryTime > toMs {
|
|
|
- return false
|
|
|
- }
|
|
|
- return true
|
|
|
-}
|
|
|
-
|
|
|
-func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool {
|
|
|
- if fromBytes <= 0 && toBytes <= 0 {
|
|
|
- return true
|
|
|
- }
|
|
|
- used := int64(0)
|
|
|
- if c.Traffic != nil {
|
|
|
- used = c.Traffic.Up + c.Traffic.Down
|
|
|
- }
|
|
|
- if fromBytes > 0 && used < fromBytes {
|
|
|
- return false
|
|
|
- }
|
|
|
- if toBytes > 0 && used > toBytes {
|
|
|
- return false
|
|
|
- }
|
|
|
- return true
|
|
|
-}
|
|
|
-
|
|
|
-func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool {
|
|
|
- switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
|
- case "on":
|
|
|
- return c.Reset > 0
|
|
|
- case "off":
|
|
|
- return c.Reset <= 0
|
|
|
- }
|
|
|
- return true
|
|
|
-}
|
|
|
-
|
|
|
-func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool {
|
|
|
- switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
|
- case "yes":
|
|
|
- return c.TgID != 0
|
|
|
- case "no":
|
|
|
- return c.TgID == 0
|
|
|
- }
|
|
|
- return true
|
|
|
-}
|
|
|
-
|
|
|
-func clientMatchesHasComment(c ClientWithAttachments, mode string) bool {
|
|
|
- switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
|
- case "yes":
|
|
|
- return strings.TrimSpace(c.Comment) != ""
|
|
|
- case "no":
|
|
|
- return strings.TrimSpace(c.Comment) == ""
|
|
|
- }
|
|
|
- return true
|
|
|
-}
|
|
|
-
|
|
|
-func clientMatchesAnyGroup(c ClientWithAttachments, csv string) bool {
|
|
|
- groups := parseCSVStrings(csv)
|
|
|
- if len(groups) == 0 {
|
|
|
- return true
|
|
|
- }
|
|
|
- current := strings.TrimSpace(c.Group)
|
|
|
- for _, g := range groups {
|
|
|
- if g == "" {
|
|
|
- if current == "" {
|
|
|
- return true
|
|
|
- }
|
|
|
- continue
|
|
|
- }
|
|
|
- if strings.EqualFold(g, current) {
|
|
|
- return true
|
|
|
- }
|
|
|
- }
|
|
|
- return false
|
|
|
-}
|
|
|
-
|
|
|
-func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
|
|
|
- if bucket == "" {
|
|
|
- return true
|
|
|
- }
|
|
|
- used := int64(0)
|
|
|
- if c.Traffic != nil {
|
|
|
- used = c.Traffic.Up + c.Traffic.Down
|
|
|
- }
|
|
|
- exhausted := c.TotalGB > 0 && used >= c.TotalGB
|
|
|
- expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
|
|
|
- switch bucket {
|
|
|
- case "online":
|
|
|
- if onlineSet == nil {
|
|
|
- return false
|
|
|
- }
|
|
|
- _, ok := onlineSet[c.Email]
|
|
|
- return ok && c.Enable
|
|
|
- case "depleted":
|
|
|
- return exhausted || expired
|
|
|
- case "deactive":
|
|
|
- return !c.Enable
|
|
|
- case "active":
|
|
|
- return c.Enable && !exhausted && !expired
|
|
|
- case "expiring":
|
|
|
- if !c.Enable || exhausted || expired {
|
|
|
- return false
|
|
|
- }
|
|
|
- nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
|
|
|
- nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
|
|
|
- return nearExpiry || nearLimit
|
|
|
- }
|
|
|
- return true
|
|
|
-}
|
|
|
-
|
|
|
-func sortClients(rows []ClientWithAttachments, sortKey, order string) {
|
|
|
- if sortKey == "" {
|
|
|
- return
|
|
|
- }
|
|
|
- desc := order == "descend"
|
|
|
- less := func(i, j int) bool {
|
|
|
- a, b := rows[i], rows[j]
|
|
|
- switch sortKey {
|
|
|
- case "enable":
|
|
|
- if a.Enable == b.Enable {
|
|
|
- return false
|
|
|
- }
|
|
|
- return !a.Enable && b.Enable
|
|
|
- case "email":
|
|
|
- return strings.ToLower(a.Email) < strings.ToLower(b.Email)
|
|
|
- case "inboundIds":
|
|
|
- return len(a.InboundIds) < len(b.InboundIds)
|
|
|
- case "traffic":
|
|
|
- ua := int64(0)
|
|
|
- if a.Traffic != nil {
|
|
|
- ua = a.Traffic.Up + a.Traffic.Down
|
|
|
- }
|
|
|
- ub := int64(0)
|
|
|
- if b.Traffic != nil {
|
|
|
- ub = b.Traffic.Up + b.Traffic.Down
|
|
|
- }
|
|
|
- return ua < ub
|
|
|
- case "remaining":
|
|
|
- ra := int64(1<<62 - 1)
|
|
|
- if a.TotalGB > 0 {
|
|
|
- used := int64(0)
|
|
|
- if a.Traffic != nil {
|
|
|
- used = a.Traffic.Up + a.Traffic.Down
|
|
|
- }
|
|
|
- ra = a.TotalGB - used
|
|
|
- }
|
|
|
- rb := int64(1<<62 - 1)
|
|
|
- if b.TotalGB > 0 {
|
|
|
- used := int64(0)
|
|
|
- if b.Traffic != nil {
|
|
|
- used = b.Traffic.Up + b.Traffic.Down
|
|
|
- }
|
|
|
- rb = b.TotalGB - used
|
|
|
- }
|
|
|
- return ra < rb
|
|
|
- case "expiryTime":
|
|
|
- ea := int64(1<<62 - 1)
|
|
|
- if a.ExpiryTime > 0 {
|
|
|
- ea = a.ExpiryTime
|
|
|
- }
|
|
|
- eb := int64(1<<62 - 1)
|
|
|
- if b.ExpiryTime > 0 {
|
|
|
- eb = b.ExpiryTime
|
|
|
- }
|
|
|
- return ea < eb
|
|
|
- case "createdAt":
|
|
|
- if a.CreatedAt == b.CreatedAt {
|
|
|
- return a.Id < b.Id
|
|
|
- }
|
|
|
- return a.CreatedAt < b.CreatedAt
|
|
|
- case "updatedAt":
|
|
|
- if a.UpdatedAt == b.UpdatedAt {
|
|
|
- return a.Id < b.Id
|
|
|
- }
|
|
|
- return a.UpdatedAt < b.UpdatedAt
|
|
|
- case "lastOnline":
|
|
|
- la := int64(0)
|
|
|
- if a.Traffic != nil {
|
|
|
- la = a.Traffic.LastOnline
|
|
|
- }
|
|
|
- lb := int64(0)
|
|
|
- if b.Traffic != nil {
|
|
|
- lb = b.Traffic.LastOnline
|
|
|
- }
|
|
|
- if la == lb {
|
|
|
- return a.Id < b.Id
|
|
|
- }
|
|
|
- return la < lb
|
|
|
- }
|
|
|
- return false
|
|
|
- }
|
|
|
- sort.SliceStable(rows, func(i, j int) bool {
|
|
|
- if desc {
|
|
|
- return less(j, i)
|
|
|
- }
|
|
|
- return less(i, j)
|
|
|
- })
|
|
|
-}
|