Reduce list-page payloads with slim/paged endpoints (#4500)

* perf(inbounds): slim list payload + lazy hydrate for row actions

Adds GET /panel/api/inbounds/list/slim that returns the same list shape
but strips every per-client field besides email/enable/comment from
settings.clients[] and skips UUID/SubId enrichment on ClientStats.
The inbounds page only reads those three to compute its client counters
and badges, so the slim variant trims tens of bytes per client (uuid,
password, flow, security, totalGB, expiryTime, limitIp, tgId, ...).
On a panel with thousands of clients this is the dominant load-time
cost.

Detail flows (edit / info / qr / export / clone) call /get/:id through
a new hydrateInbound helper before opening — the slim list view never
needs the secrets it doesn't render.

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

* perf(settings): use /inbounds/options for LDAP tag picker

The General settings tab only needs each inbound's tag/protocol/port to
fill a dropdown but was calling /panel/api/inbounds/list which ships the
full settings JSON with every embedded client. Switched it to /options
and added Tag to the projection. On a panel with thousands of clients
this drops the General-tab load payload from megabytes to a tiny
per-inbound row each.

* perf(clients): de-duplicate options + paged list fetches

Two issues caused each clients-page load to fire its requests twice:

1. setQuery in the hook took whatever object the consumer passed and
   stored it as-is. The consumer (ClientsPage) constructs a new object
   literal in an effect, so even when nothing actually changed the ref
   was new — the hook's useEffect saw a new query and re-fetched.
   Wrapped setQuery with a shallow value compare so identical params
   are a no-op.

2. The picker /inbounds/options fetch was bundled into refresh() with a
   length==0 guard, but the two back-to-back refreshes both saw an
   empty inbounds array (the first hadn't resolved yet) so both fired
   the request. Moved the options fetch into its own one-shot effect.

* perf(inbounds): share nodes list with form modal instead of refetching

InboundsPage and InboundFormModal both called useNodes() — each
instance maintains its own state and fires its own /panel/api/nodes/list
fetch on mount. Since the modal is always rendered (open or not), every
page load hit the endpoint twice.

Threaded nodes from the page through an availableNodes prop on the form
modal so they share one fetch.

* docs(api): register /clients/list/paged endpoint

TestAPIRoutesDocumented was failing because the new paginated clients
endpoint added in this branch wasn't listed in endpoints.js.
This commit is contained in:
Sanaei
2026-05-23 17:43:43 +02:00
committed by GitHub
parent 9c60ed7ea8
commit c5b71041d3
11 changed files with 700 additions and 124 deletions
+108 -14
View File
@@ -54,12 +54,65 @@ 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, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
// Shallow-compare against the previous query so callers can pass a fresh
// object on every render (the common React pattern) without triggering a
// re-fetch when nothing actually changed.
const setQuery = useCallback((next: ClientQueryParams) => {
setQueryState((prev) => {
if (
prev.page === next.page
&& prev.pageSize === next.pageSize
&& (prev.search ?? '') === (next.search ?? '')
&& (prev.filter ?? '') === (next.filter ?? '')
&& (prev.protocol ?? '') === (next.protocol ?? '')
&& (prev.sort ?? '') === (next.sort ?? '')
&& (prev.order ?? '') === (next.order ?? '')
) return prev;
return next;
});
}, []);
const [subSettings, setSubSettings] = useState<SubSettings>({
enable: false, subURI: '', subJsonURI: '', subJsonEnable: false,
});
@@ -70,22 +123,35 @@ 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 [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[]>>,
]);
if (clientsMsg?.success) {
setClients(Array.isArray(clientsMsg.obj) ? clientsMsg.obj : []);
}
if (inboundsMsg?.success) {
setInbounds(Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : []);
const params = override ?? queryRef.current;
const qs = buildQS(params);
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`) as ApiMsg<ClientPageResponse>;
if (msg?.success && msg.obj) {
setClients(Array.isArray(msg.obj.items) ? msg.obj.items : []);
setTotal(msg.obj.total ?? 0);
setFiltered(msg.obj.filtered ?? 0);
if (msg.obj.summary) setSummary(msg.obj.summary);
}
setFetched(true);
} finally {
@@ -93,6 +159,18 @@ export function useClients() {
}
}, []);
// Inbound options are picker-shaped and don't depend on the clients query —
// fetch them once on mount instead of every refresh.
useEffect(() => {
let cancelled = false;
(async () => {
const msg = await HttpUtil.get('/panel/api/inbounds/options') as ApiMsg<InboundOption[]>;
if (cancelled) return;
if (msg?.success) setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
})();
return () => { cancelled = true; };
}, []);
const fetchSubSettings = useCallback(async () => {
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg<Record<string, unknown>>;
if (!msg?.success) return;
@@ -110,6 +188,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 +347,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,