From b3db26c4d8e1a668b5b37a4f182d111fad918380 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 23 May 2026 17:23:42 +0200 Subject: [PATCH] perf(clients): server-side pagination + slim row payload Adds GET /panel/api/clients/list/paged that filters, sorts, and paginates on the server, returns a slim row shape (drops uuid/password/auth/flow/ security/reverse/tgId per client), and includes a stable summary (total, active, online[], depleted[], expiring[], deactive[]) computed across the full DB row set so the dashboard cards don't change as the user paginates or filters. Page size capped at 200. useClients now exposes { clients (current page), total, filtered, query, setQuery, summary, hydrate }. ClientsPage feeds its filter/sort/page state into setQuery via a single effect, debounces search by 300ms, and hydrates the full client record via /get/:email before opening edit/info/ qr modals. Local filter/sort logic and the all-clients summary memo are gone. On a 2000-client panel this turns the initial response from ~MB to ~25 row slice (~10s of KB) and removes the all-client parse cost from every refresh. --- frontend/src/hooks/useClients.ts | 93 +++++- frontend/src/pages/clients/ClientsPage.tsx | 149 ++++----- web/controller/client.go | 16 + web/service/client.go | 346 +++++++++++++++++++++ 4 files changed, 507 insertions(+), 97 deletions(-) diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index a2089ad9..92dc9521 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -54,12 +54,48 @@ interface SubSettings { subJsonEnable: boolean; } +export interface ClientQueryParams { + page: number; + pageSize: number; + search?: string; + filter?: string; + protocol?: string; + sort?: string; + order?: 'ascend' | 'descend'; +} + +export interface ClientsSummary { + total: number; + active: number; + online: string[]; + depleted: string[]; + expiring: string[]; + deactive: string[]; +} + +interface ClientPageResponse { + items: ClientRecord[]; + total: number; + filtered: number; + page: number; + pageSize: number; + summary?: ClientsSummary; +} + +const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 }; + export function useClients() { const [clients, setClients] = useState([]); + const [total, setTotal] = useState(0); + const [filtered, setFiltered] = useState(0); + const [summary, setSummary] = useState({ + total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [], + }); const [inbounds, setInbounds] = useState([]); const [onlines, setOnlines] = useState([]); const [loading, setLoading] = useState(false); const [fetched, setFetched] = useState(false); + const [query, setQuery] = useState(DEFAULT_QUERY); const [subSettings, setSubSettings] = useState({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false, }); @@ -70,19 +106,40 @@ export function useClients() { const [pageSize, setPageSize] = useState(0); const clientsRef = useRef([]); + const queryRef = useRef(query); const invalidateTimerRef = useRef(null); useEffect(() => { clientsRef.current = clients; }, [clients]); + useEffect(() => { queryRef.current = query; }, [query]); - const refresh = useCallback(async () => { + const buildQS = (p: ClientQueryParams) => { + const sp = new URLSearchParams(); + sp.set('page', String(p.page || 1)); + sp.set('pageSize', String(p.pageSize || DEFAULT_QUERY.pageSize)); + if (p.search) sp.set('search', p.search); + if (p.filter) sp.set('filter', p.filter); + if (p.protocol) sp.set('protocol', p.protocol); + if (p.sort) sp.set('sort', p.sort); + if (p.order) sp.set('order', p.order); + return sp.toString(); + }; + + const refresh = useCallback(async (override?: ClientQueryParams) => { setLoading(true); try { + const params = override ?? queryRef.current; + const qs = buildQS(params); const [clientsMsg, inboundsMsg] = await Promise.all([ - HttpUtil.get('/panel/api/clients/list') as Promise>, - HttpUtil.get('/panel/api/inbounds/options') as Promise>, + HttpUtil.get(`/panel/api/clients/list/paged?${qs}`) as Promise>, + inbounds.length === 0 + ? HttpUtil.get('/panel/api/inbounds/options') as Promise> + : Promise.resolve(null as ApiMsg | null), ]); - if (clientsMsg?.success) { - setClients(Array.isArray(clientsMsg.obj) ? clientsMsg.obj : []); + if (clientsMsg?.success && clientsMsg.obj) { + setClients(Array.isArray(clientsMsg.obj.items) ? clientsMsg.obj.items : []); + setTotal(clientsMsg.obj.total ?? 0); + setFiltered(clientsMsg.obj.filtered ?? 0); + if (clientsMsg.obj.summary) setSummary(clientsMsg.obj.summary); } if (inboundsMsg?.success) { setInbounds(Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : []); @@ -91,7 +148,7 @@ export function useClients() { } finally { setLoading(false); } - }, []); + }, [inbounds.length]); const fetchSubSettings = useCallback(async () => { const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg>; @@ -110,6 +167,17 @@ export function useClients() { setPageSize((s.pageSize as number) ?? 0); }, []); + // hydrate fetches the full client record (uuid, password, flow, ...) for a + // single email. The paged list endpoint omits these to keep the row payload + // tiny; edit / info / qr / link modals call this to get a complete record + // before opening. + const hydrate = useCallback(async (email: string): Promise<{ client: ClientRecord; inboundIds: number[] } | null> => { + if (!email) return null; + const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`) as ApiMsg<{ client: ClientRecord; inboundIds: number[] }>; + if (!msg?.success || !msg.obj) return null; + return msg.obj; + }, []); + const create = useCallback(async (payload: unknown) => { const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as ApiMsg; if (msg?.success) await refresh(); @@ -258,13 +326,18 @@ export function useClients() { }, [refresh]); useEffect(() => { - - Promise.all([refresh(), fetchSubSettings()]); - - }, [refresh, fetchSubSettings]); + Promise.all([refresh(query), fetchSubSettings()]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, fetchSubSettings]); return { clients, + total, + filtered, + summary, + hydrate, + query, + setQuery, inbounds, onlines, loading, diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 788df288..06d015f7 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -49,7 +49,7 @@ import { useDatepicker } from '@/hooks/useDatepicker'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import AppSidebar from '@/components/AppSidebar'; import CustomStatistic from '@/components/CustomStatistic'; -import { IntlUtil, ObjectUtil, SizeFormatter } from '@/utils'; +import { IntlUtil, SizeFormatter } from '@/utils'; import { setMessageInstance } from '@/utils/messageBus'; import ClientFormModal from './ClientFormModal'; import ClientInfoModal from './ClientInfoModal'; @@ -96,11 +96,15 @@ export default function ClientsPage() { useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); const { - clients, inbounds, onlines, loading, fetched, subSettings, + clients, filtered, + summary: serverSummary, + setQuery, + inbounds, onlines, loading, fetched, subSettings, ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize, create, update, remove, removeMany, bulkAdjust, attach, detach, resetTraffic, resetAllTraffics, delDepleted, setEnable, applyTrafficEvent, applyClientStatsEvent, applyInvalidate, + hydrate, } = useClients(); useWebSocket({ @@ -131,7 +135,10 @@ export default function ClientsPage() { const [sortColumn, setSortColumn] = useState(null); const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null); const [currentPage, setCurrentPage] = useState(1); - const [tablePageSize, setTablePageSize] = useState(20); + const [tablePageSize, setTablePageSize] = useState(25); + // debouncedSearch lags behind the input so we don't spam the server on every + // keystroke; the search box still feels instant locally. + const [debouncedSearch, setDebouncedSearch] = useState(searchKey); useEffect(() => { localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ @@ -139,6 +146,29 @@ export default function ClientsPage() { })); }, [enableFilter, searchKey, filterBy, protocolFilter]); + useEffect(() => { + const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300); + return () => window.clearTimeout(handle); + }, [searchKey]); + + useEffect(() => { + // Reset to page 1 whenever a filter or sort changes — otherwise an empty + // result set on a high page number leaves the user staring at "no clients". + setCurrentPage(1); + }, [debouncedSearch, enableFilter, filterBy, protocolFilter, sortColumn, sortOrder]); + + useEffect(() => { + setQuery({ + page: currentPage, + pageSize: tablePageSize, + search: enableFilter ? '' : debouncedSearch, + filter: enableFilter ? (filterBy || '') : '', + protocol: protocolFilter || '', + sort: sortColumn || undefined, + order: sortOrder || undefined, + }); + }, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, sortColumn, sortOrder]); + useEffect(() => { if (pageSize > 0) { @@ -192,81 +222,18 @@ export default function ClientsPage() { } } - function clientMatchesProtocol(row: ClientRecord, protocol?: string) { - if (!protocol) return true; - const ids = Array.isArray(row.inboundIds) ? row.inboundIds : []; - for (const id of ids) { - const ib = inboundsById[id]; - if (ib && ib.protocol === protocol) return true; - } - return false; - } + // The list page renders rows the server already sorted, filtered, and + // paginated. Local filtering is gone — keep the variable name so the rest + // of the file (table dataSource, mobile cards, select-all) doesn't need + // a rename. + const filteredClients = clients; - const filteredClients = useMemo(() => { - let rows = clients || []; - if (enableFilter) { - if (filterBy === 'online') { - rows = rows.filter((r) => r.enable && isOnline(r.email)); - } else if (filterBy) { - rows = rows.filter((r) => clientBucket(r) === filterBy); - } - } else if (!ObjectUtil.isEmpty(searchKey)) { - rows = rows.filter((r) => ObjectUtil.deepSearch(r, searchKey)); - } - if (protocolFilter) { - rows = rows.filter((r) => clientMatchesProtocol(r, protocolFilter)); - } - return rows; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [clients, enableFilter, filterBy, searchKey, protocolFilter, clientBucket]); + // Server-computed counts that stay stable as the user paginates/filters. + const summary = serverSummary; - const summary = useMemo(() => { - const rows = clients || []; - const deactive: string[] = []; - const depleted: string[] = []; - const expiring: string[] = []; - const online: string[] = []; - let active = 0; - for (const row of rows) { - const bucket = clientBucket(row); - if (bucket === 'deactive') deactive.push(row.email); - else if (bucket === 'depleted') depleted.push(row.email); - else if (bucket === 'expiring') expiring.push(row.email); - else if (bucket === 'active') active++; - if (row.enable && isOnline(row.email)) online.push(row.email); - } - return { total: rows.length, active, deactive, depleted, expiring, online }; - }, [clients, clientBucket, isOnline]); - - const sortFns: Record number> = { - enable: (a, b) => Number(a.enable) - Number(b.enable), - email: (a, b) => (a.email || '').localeCompare(b.email || ''), - inboundIds: (a, b) => (a.inboundIds?.length || 0) - (b.inboundIds?.length || 0), - traffic: (a, b) => { - const ua = (a.traffic?.up || 0) + (a.traffic?.down || 0); - const ub = (b.traffic?.up || 0) + (b.traffic?.down || 0); - return ua - ub; - }, - remaining: (a, b) => { - const ra = (a.totalGB || 0) > 0 ? (a.totalGB || 0) - ((a.traffic?.up || 0) + (a.traffic?.down || 0)) : Infinity; - const rb = (b.totalGB || 0) > 0 ? (b.totalGB || 0) - ((b.traffic?.up || 0) + (b.traffic?.down || 0)) : Infinity; - return ra - rb; - }, - expiryTime: (a, b) => { - const ea = (a.expiryTime ?? 0) > 0 ? (a.expiryTime ?? 0) : Infinity; - const eb = (b.expiryTime ?? 0) > 0 ? (b.expiryTime ?? 0) : Infinity; - return ea - eb; - }, - }; - - const sortedClients = useMemo(() => { - if (!sortColumn || !sortOrder) return filteredClients; - const fn = sortFns[sortColumn]; - if (!fn) return filteredClients; - const sorted = [...filteredClients].sort(fn); - return sortOrder === 'descend' ? sorted.reverse() : sorted; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filteredClients, sortColumn, sortOrder]); + // Sort is server-side now; the page already arrives in the requested + // order, so we just hand it through. + const sortedClients = filteredClients; function trafficLabel(row: ClientRecord) { const t0 = row.traffic; @@ -341,10 +308,15 @@ export default function ClientsPage() { setFormOpen(true); } - function onEdit(row: ClientRecord) { + async function onEdit(row: ClientRecord) { setFormMode('edit'); - setEditingClient({ ...row }); - setEditingAttachedIds(Array.isArray(row.inboundIds) ? [...row.inboundIds] : []); + // Paged list omits per-client secrets to keep the row payload tiny; + // edit needs them, so fetch the full record first. + const full = await hydrate(row.email); + const merged: ClientRecord = full ? { ...row, ...full.client } : { ...row }; + setEditingClient(merged); + const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []); + setEditingAttachedIds([...ids]); setFormOpen(true); } @@ -379,13 +351,15 @@ export default function ClientsPage() { }); } - function onShowInfo(row: ClientRecord) { - setInfoClient(row); + async function onShowInfo(row: ClientRecord) { + const full = await hydrate(row.email); + setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row); setInfoOpen(true); } - function onShowQr(row: ClientRecord) { - setQrClient(row); + async function onShowQr(row: ClientRecord) { + const full = await hydrate(row.email); + setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row); setQrOpen(true); } @@ -595,10 +569,11 @@ export default function ClientsPage() { const tablePagination = { current: currentPage, pageSize: tablePageSize, - total: sortedClients.length, - showSizeChanger: sortedClients.length > 10, - pageSizeOptions: ['10', '20', '50', '100'], - hideOnSinglePage: sortedClients.length <= tablePageSize, + total: filtered, + showSizeChanger: filtered > 10, + pageSizeOptions: ['10', '25', '50', '100', '200'], + hideOnSinglePage: filtered <= tablePageSize, + showTotal: (n: number) => `${n}`, }; const rowSelection = { diff --git a/web/controller/client.go b/web/controller/client.go index fe567488..b2d1837b 100644 --- a/web/controller/client.go +++ b/web/controller/client.go @@ -20,6 +20,7 @@ type ClientController struct { clientService service.ClientService inboundService service.InboundService xrayService service.XrayService + settingService service.SettingService } func NewClientController(g *gin.RouterGroup) *ClientController { @@ -30,6 +31,7 @@ func NewClientController(g *gin.RouterGroup) *ClientController { func (a *ClientController) initRouter(g *gin.RouterGroup) { g.GET("/list", a.list) + g.GET("/list/paged", a.listPaged) g.GET("/get/:email", a.get) g.GET("/traffic/:email", a.getTrafficByEmail) g.GET("/subLinks/:subId", a.getSubLinks) @@ -60,6 +62,20 @@ func (a *ClientController) list(c *gin.Context) { jsonObj(c, rows, nil) } +func (a *ClientController) listPaged(c *gin.Context) { + var params service.ClientPageParams + if err := c.ShouldBindQuery(¶ms); err != nil { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) + return + } + resp, err := a.clientService.ListPaged(&a.inboundService, &a.settingService, params) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) + return + } + jsonObj(c, resp, nil) +} + func (a *ClientController) get(c *gin.Context) { email := c.Param("email") rec, err := a.clientService.GetRecordByEmail(nil, email) diff --git a/web/service/client.go b/web/service/client.go index d9d726e4..92b27566 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "sort" "strings" "sync" "time" @@ -803,6 +804,351 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st return needRestart, nil } +// ClientSlim is the row-shape used by the clients page. It drops fields the +// table never reads (UUID, password, auth, flow, security, reverse, tgId) +// so the list payload stays compact even when the panel manages thousands +// of clients. Modals that need the full record still call /get/:email. +type ClientSlim struct { + Email string `json:"email"` + SubID string `json:"subId"` + Enable bool `json:"enable"` + TotalGB int64 `json:"totalGB"` + ExpiryTime int64 `json:"expiryTime"` + LimitIP int `json:"limitIp"` + Reset int `json:"reset"` + Comment string `json:"comment,omitempty"` + InboundIds []int `json:"inboundIds"` + Traffic *xray.ClientTraffic `json:"traffic,omitempty"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +// ClientPageParams are the query params accepted by /panel/api/clients/list/paged. +// All fields are optional — the empty value means "no filter" / defaults. +type ClientPageParams struct { + Page int `form:"page"` + PageSize int `form:"pageSize"` + Search string `form:"search"` + Filter string `form:"filter"` + Protocol string `form:"protocol"` + Sort string `form:"sort"` + Order string `form:"order"` +} + +// ClientPageResponse is the shape returned by ListPaged. `Total` is the +// row count in the DB; `Filtered` is the count after Search/Filter/Protocol +// were applied, before pagination. The page contains at most PageSize items. +// Summary is computed across the full DB row set so dashboard counters +// on the clients page stay stable as the user paginates/filters. +type ClientPageResponse struct { + Items []ClientSlim `json:"items"` + Total int `json:"total"` + Filtered int `json:"filtered"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Summary ClientsSummary `json:"summary"` +} + +// 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. +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"` +} + +const ( + clientPageDefaultSize = 25 + clientPageMaxSize = 200 +) + +// 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 + } + total := len(all) + + pageSize := params.PageSize + if pageSize <= 0 { + pageSize = clientPageDefaultSize + } + if pageSize > clientPageMaxSize { + pageSize = clientPageMaxSize + } + page := params.Page + if page <= 0 { + page = 1 + } + + var protocolByInbound map[int]string + if params.Protocol != "" { + 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 { + expireDiffMs = int64(v) * 86400000 + } + if v, err := settingSvc.GetTrafficDiff(); err == nil { + trafficDiffBytes = int64(v) * 1073741824 + } + } + + nowMs := time.Now().UnixMilli() + summary := buildClientsSummary(all, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) + + needle := strings.ToLower(strings.TrimSpace(params.Search)) + + filtered := make([]ClientWithAttachments, 0, len(all)) + for _, c := range all { + if needle != "" && !clientMatchesSearch(c, needle) { + continue + } + if params.Protocol != "" && !clientMatchesProtocol(c, params.Protocol, protocolByInbound) { + continue + } + if params.Filter != "" && !clientMatchesBucket(c, params.Filter, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) { + continue + } + filtered = append(filtered, c) + } + + 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 + } + pageRows := filtered[start:end] + + items := make([]ClientSlim, 0, len(pageRows)) + for _, c := range pageRows { + items = append(items, toClientSlim(c)) + } + + return &ClientPageResponse{ + Items: items, + Total: total, + Filtered: filteredCount, + Page: page, + PageSize: pageSize, + Summary: summary, + }, nil +} + +func buildClientsSummary(all []ClientWithAttachments, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) ClientsSummary { + s := ClientsSummary{ + Total: len(all), + 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) + continue + } + if !c.Enable { + s.Deactive = append(s.Deactive, c.Email) + continue + } + 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++ + } + } + 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, + Comment: c.Comment, + InboundIds: c.InboundIds, + Traffic: c.Traffic, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} + +func clientMatchesSearch(c ClientWithAttachments, needle string) bool { + if needle == "" { + return true + } + if strings.Contains(strings.ToLower(c.Email), needle) { + return true + } + if strings.Contains(strings.ToLower(c.SubID), needle) { + return true + } + if strings.Contains(strings.ToLower(c.Comment), needle) { + return true + } + return false +} + +func clientMatchesProtocol(c ClientWithAttachments, protocol string, byInbound map[int]string) bool { + if protocol == "" { + return true + } + for _, id := range c.InboundIds { + if byInbound[id] == protocol { + 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 + } + return false + } + sort.SliceStable(rows, func(i, j int) bool { + if desc { + return less(j, i) + } + return less(i, j) + }) +} + // BulkAdjustResult is returned by BulkAdjust to report how many clients were // successfully updated and which were skipped (typically because the field // being adjusted was unlimited for that client) or failed.