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.
This commit is contained in:
MHSanaei 2026-05-23 17:23:42 +02:00
parent 6279c6d849
commit b3db26c4d8
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
4 changed files with 507 additions and 97 deletions

View file

@ -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<ClientRecord[]>([]);
const [total, setTotal] = useState(0);
const [filtered, setFiltered] = useState(0);
const [summary, setSummary] = useState<ClientsSummary>({
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
});
const [inbounds, setInbounds] = useState<InboundOption[]>([]);
const [onlines, setOnlines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false);
const [query, setQuery] = useState<ClientQueryParams>(DEFAULT_QUERY);
const [subSettings, setSubSettings] = useState<SubSettings>({
enable: false, subURI: '', subJsonURI: '', subJsonEnable: false,
});
@ -70,19 +106,40 @@ export function useClients() {
const [pageSize, setPageSize] = useState(0);
const clientsRef = useRef<ClientRecord[]>([]);
const queryRef = useRef<ClientQueryParams>(query);
const invalidateTimerRef = useRef<number | null>(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<ApiMsg<ClientRecord[]>>,
HttpUtil.get('/panel/api/inbounds/options') as Promise<ApiMsg<InboundOption[]>>,
HttpUtil.get(`/panel/api/clients/list/paged?${qs}`) as Promise<ApiMsg<ClientPageResponse>>,
inbounds.length === 0
? HttpUtil.get('/panel/api/inbounds/options') as Promise<ApiMsg<InboundOption[]>>
: Promise.resolve(null as ApiMsg<InboundOption[]> | 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<Record<string, unknown>>;
@ -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,

View file

@ -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<string | null>(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<string, (a: ClientRecord, b: ClientRecord) => 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 = {

View file

@ -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(&params); 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)

View file

@ -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.