mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
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:
parent
6279c6d849
commit
b3db26c4d8
4 changed files with 507 additions and 97 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue