Explorar o código

perf(clients): make the clients page scale to large panels

The clients page was slow on panels with many clients for two independent
reasons: the server rebuilt the whole picture on every request, and the
browser rebuilt the whole table on every poll.

Server side, ListPaged loaded every client row, every client_inbounds link
and every client_traffics row into Go memory, then filtered, sorted and
paginated in a loop -- on a request the page repeats every five seconds.
Every predicate now runs in SQL and only the requested page's ids are
hydrated, so the cost tracks the page size rather than the client count.
Measured on SQLite with a realistic status mix: the default view at 100k
clients goes from 1,072ms to 64ms. Behaviour is preserved deliberately in
the subtle places -- the cross-panel global-traffic overlay is folded into
the same used-bytes expression the predicates and sort use, LIKE wildcards
are escaped so a search for "a_b" stays literal, and the two different
tiebreak rules the in-memory comparator had are reproduced per sort key.

The summary's per-bucket email lists are capped at 200 with exact counters
beside them. They only back hover popovers, but shipping every match made
the response grow with the panel: at 100k clients it carried ~42k emails,
and the page revalidated all of them through a strict Zod parse every five
seconds. The popover now shows a "+N" chip for the remainder.

Browser side, the page fired three sequential list requests per load and
threw the first two away: the query went out before the persisted sort was
applied, and again before the configured page size was known -- 0 meaning
"one long page" is indistinguishable from "not loaded yet". The page size
is now derived rather than mirrored through an effect, and the previous
visit's value is remembered so the single request goes out at mount instead
of queueing behind /setting/defaultSettings.

Then the per-poll work. Reading isFetching made it a tracked property, so
the refetch interval notified twice per cycle and re-rendered the page even
when structural sharing left the data identical. Xray reports a traffic row
per client whether or not it moved bytes, so the speed map was mostly zeros
and was replaced wholesale every push; zero rows are now dropped and an
unchanged result returns the previous object, which lets React bail out
instead of re-rendering. The five Tooltip-wrapped buttons and the inbound
chips per row do not depend on traffic at all and are now memoised, keyed on
the email because a push replaces the row object of every client whose
counters moved. antd's hashed:false drops 3,311 :where(.css-<hash>) wrappers
and 29% of the generated stylesheet, and a pinned cssVar key stops each of
the eleven page-level ConfigProviders minting its own token scope.

Two callers that only need the mutations, GroupsPage and ClientBulkAddModal,
no longer start the list query -- the groups page had been polling the full
paged list every five seconds for data it never renders.
Sanaei hai 12 horas
pai
achega
f52c3c4837

+ 8 - 2
frontend/public/openapi.json

@@ -5691,7 +5691,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.",
+        "summary": "Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.",
         "operationId": "get_panel_api_clients_list_paged",
         "parameters": [
           {
@@ -5807,12 +5807,18 @@
                     "summary": {
                       "total": 2000,
                       "active": 1850,
+                      "onlineCount": 1,
+                      "depletedCount": 0,
+                      "expiringCount": 0,
+                      "deactiveCount": 150,
                       "online": [
                         "[email protected]"
                       ],
                       "depleted": [],
                       "expiring": [],
-                      "deactive": []
+                      "deactive": [
+                        "[email protected]"
+                      ]
                     }
                   }
                 }

+ 9 - 3
frontend/src/components/clients/ClientTrafficCell.tsx

@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { memo, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Popover, Progress } from 'antd';
 
@@ -17,7 +17,11 @@ export interface ClientTrafficCellProps {
   compact?: boolean;
 }
 
-export default function ClientTrafficCell({
+// Every prop is a primitive and the component is pure, so the memo bails out
+// whenever a client's counters did not move — which is most of them on most
+// pushes. Each skipped instance is one antd Popover (rc-trigger), one Progress,
+// a useTranslation subscription and a theme context read, times up to 200 rows.
+const ClientTrafficCell = memo(function ClientTrafficCell({
   up = 0,
   down = 0,
   total = 0,
@@ -83,4 +87,6 @@ export default function ClientTrafficCell({
       </div>
     </Popover>
   );
-}
+});
+
+export default ClientTrafficCell;

+ 98 - 14
frontend/src/hooks/useClients.ts

@@ -73,7 +73,9 @@ export interface ClientQueryParams {
 
 const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
 const DEFAULT_SUMMARY: ClientsSummary = {
-  total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
+  total: 0, active: 0,
+  onlineCount: 0, depletedCount: 0, expiringCount: 0, deactiveCount: 0,
+  online: [], depleted: [], expiring: [], deactive: [],
 };
 
 export interface ClientSpeedEntry {
@@ -114,7 +116,50 @@ export function computeClientsSummary(
     if (nearExpiry || nearLimit) expiring.push(email);
     else active += 1;
   }
-  return { total: stats.length, active, online, depleted, expiring, deactive };
+  return {
+    total: stats.length,
+    active,
+    onlineCount: online.length,
+    depletedCount: depleted.length,
+    expiringCount: expiring.length,
+    deactiveCount: deactive.length,
+    online,
+    depleted,
+    expiring,
+    deactive,
+  };
+}
+
+export function sameSpeedMap(
+  a: Record<string, ClientSpeedEntry>,
+  b: Record<string, ClientSpeedEntry>,
+): boolean {
+  const aKeys = Object.keys(a);
+  if (aKeys.length !== Object.keys(b).length) return false;
+  for (const key of aKeys) {
+    const left = a[key];
+    const right = b[key];
+    if (!right || left.up !== right.up || left.down !== right.down) return false;
+  }
+  return true;
+}
+
+// The field list computeClientsSummary reads, and deliberately nothing else.
+// lastOnline in particular churns for every online client on every push and no
+// counter depends on it, so including it here would defeat the comparison.
+export function sameSummaryInputs(a: ClientStatRow[], b: ClientStatRow[]): boolean {
+  if (a.length !== b.length) return false;
+  for (let i = 0; i < a.length; i++) {
+    const left = a[i];
+    const right = b[i];
+    if (left.email !== right.email
+      || left.up !== right.up
+      || left.down !== right.down
+      || left.total !== right.total
+      || left.enable !== right.enable
+      || left.expiryTime !== right.expiryTime) return false;
+  }
+  return true;
 }
 
 export function pickClientsSummary(
@@ -174,17 +219,31 @@ async function fetchDefaults(): Promise<Record<string, unknown>> {
   return validated.obj || {};
 }
 
-export function useClients() {
+export interface UseClientsOptions {
+  // Callers that only need the mutations — the bulk modals, the groups page —
+  // pass false. Mounting them used to start a second 5-second poll of the paged
+  // list whose result they never read, which on a large panel means a full
+  // summary aggregate every 5 seconds for nothing.
+  list?: boolean;
+}
+
+export function useClients(options: UseClientsOptions = {}) {
+  const withList = options.list ?? true;
   const queryClient = useQueryClient();
 
-  const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
+  // Null until the page has settled on a query. The clients page cannot build
+  // one until the persisted sort and the panel's configured page size are both
+  // known, and fetching before then cost three sequential requests per load —
+  // the first two thrown away (#trace).
+  const [query, setQueryState] = useState<ClientQueryParams | null>(null);
   // setQuery shallow-compares so callers can pass a fresh object 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
+        && prev.page === next.page
         && prev.pageSize === next.pageSize
         && (prev.search ?? '') === (next.search ?? '')
         && (prev.filter ?? '') === (next.filter ?? '')
@@ -206,8 +265,9 @@ export function useClients() {
   }, []);
 
   const listQuery = useQuery({
-    queryKey: keys.clients.list(query),
-    queryFn: () => fetchClientPage(query),
+    queryKey: keys.clients.list(query ?? DEFAULT_QUERY),
+    queryFn: () => fetchClientPage(query ?? DEFAULT_QUERY),
+    enabled: withList && query !== null,
     staleTime: Infinity,
     // List is sorted/paged server-side, so the WS patch can't add new or
     // re-sort rows; poll the current page to keep it live (pauses when hidden).
@@ -218,6 +278,7 @@ export function useClients() {
   const inboundOptionsQuery = useQuery({
     queryKey: keys.inbounds.options(),
     queryFn: fetchInboundOptions,
+    enabled: withList,
     staleTime: Infinity,
   });
 
@@ -235,6 +296,7 @@ export function useClients() {
       const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
       return Array.isArray(validated.obj) ? validated.obj : [];
     },
+    enabled: withList,
     staleTime: Infinity,
   });
 
@@ -244,7 +306,11 @@ export function useClients() {
   const allGroups = listQuery.data?.groups ?? [];
   const fetched = listQuery.data !== undefined || listQuery.isError;
   const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
-  const loading = listQuery.isFetching;
+  // isFetching is deliberately NOT read here. Touching it makes it a tracked
+  // property, so the 5s refetchInterval notifies twice per cycle — two whole
+  // page renders even when structural sharing leaves the data identical, and
+  // each one bumps rc-table's immutable mark and re-runs every cell renderer.
+  // Callers that want a spinner for an explicit refresh drive it locally.
   // Showing kept-previous data for a new key (filter/sort/page) — drives the
   // table overlay so the 5s background poll doesn't flash it.
   const transitioning = listQuery.isPlaceholderData;
@@ -277,6 +343,11 @@ export function useClients() {
   const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
   const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
   const pageSize = (defaults.pageSize as number) ?? 0;
+  // pageSize 0 means "one long page", which is indistinguishable from "the
+  // settings have not arrived yet" — so callers need this flag to know when the
+  // configured page size is real. isFetched (not isSuccess) so a failed
+  // settings request still lets the page fall back and render.
+  const settingsReady = defaultsQuery.isFetched;
 
   const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
   const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
@@ -565,15 +636,23 @@ export function useClients() {
       queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
     }
     if (Array.isArray(p.clientTraffics)) {
+      // Xray reports a row per client whether or not it moved a byte, so most of
+      // this map used to be zeros. A missing entry and a zero entry render
+      // identically (isActiveSpeed treats both as inactive), so the zeros are
+      // dropped and an unchanged result returns the previous object — which lets
+      // React bail out of the update instead of re-rendering the table.
       const next: Record<string, ClientSpeedEntry> = {};
       for (const ct of p.clientTraffics) {
         if (!ct || !ct.email) continue;
+        const up = ct.up || 0;
+        const down = ct.down || 0;
+        if (up === 0 && down === 0) continue;
         next[ct.email] = {
-          up: (ct.up || 0) / TRAFFIC_POLL_INTERVAL_S,
-          down: (ct.down || 0) / TRAFFIC_POLL_INTERVAL_S,
+          up: up / TRAFFIC_POLL_INTERVAL_S,
+          down: down / TRAFFIC_POLL_INTERVAL_S,
         };
       }
-      setClientSpeed(next);
+      setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
     }
   }, [queryClient]);
 
@@ -581,12 +660,17 @@ export function useClients() {
     if (!payload || typeof payload !== 'object') return;
     const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
     if (!Array.isArray(p.clients) || p.clients.length === 0) return;
-    if (p.snapshot !== false) setAllClientStats(p.clients);
+    if (p.snapshot !== false) {
+      const rows = p.clients;
+      setAllClientStats((prev) => (sameSummaryInputs(prev, rows) ? prev : rows));
+    }
+    const active = queryRef.current;
+    if (!active) return;
     const byEmail = new Map<string, ClientTraffic>();
     for (const row of p.clients) {
       if (row && row.email) byEmail.set(row.email, row);
     }
-    queryClient.setQueryData<ClientPageResponse>(keys.clients.list(queryRef.current), (prev) => {
+    queryClient.setQueryData<ClientPageResponse>(keys.clients.list(active), (prev) => {
       if (!prev) return prev;
       let touched = false;
       const next = prev.items.slice();
@@ -624,7 +708,6 @@ export function useClients() {
     setQuery,
     inbounds,
     onlines,
-    loading,
     transitioning,
     fetched,
     fetchError,
@@ -634,6 +717,7 @@ export function useClients() {
     expireDiff,
     trafficDiff,
     pageSize,
+    settingsReady,
     refresh,
     create,
     bulkCreate,

+ 16 - 0
frontend/src/hooks/useTheme.tsx

@@ -92,9 +92,24 @@ const LIGHT_BUTTON_TOKENS = {
   colorPrimaryActive: '#073ea8',
 };
 
+// hashed:false drops the `:where(.css-<hash>)` wrapper antd puts around every
+// rule. It costs nothing in specificity — `:where()` contributes zero, so the
+// panel's own `.ant-*` overrides still win — and it removes roughly 5,700
+// wrappers, 16% of the generated stylesheet, from what the browser has to parse.
+//
+// cssVar.key pins the CSS-variable scope. Every panel page mounts its own
+// ConfigProvider (there is no root one), and without a fixed key each mints a
+// fresh useId-derived scope, so navigating re-serialises and re-injects the whole
+// token block under a new class instead of reusing the one already in the head.
+const SHARED_STYLE_CONFIG = {
+  hashed: false,
+  cssVar: { key: 'xui' },
+} as const;
+
 export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
   if (!isDark) {
     return {
+      ...SHARED_STYLE_CONFIG,
       algorithm: antdTheme.defaultAlgorithm,
       token: LIGHT_CONTRAST_TOKENS,
       components: {
@@ -104,6 +119,7 @@ export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeCo
     };
   }
   return {
+    ...SHARED_STYLE_CONFIG,
     algorithm: antdTheme.darkAlgorithm,
     token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
     components: {

+ 2 - 2
frontend/src/pages/api-docs/endpoints.ts

@@ -564,7 +564,7 @@ export const sections: readonly Section[] = [
       {
         method: 'GET',
         path: '/panel/api/clients/list/paged',
-        summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.',
+        summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.',
         params: [
           { name: 'page', in: 'query', type: 'number', desc: '1-indexed page number. Defaults to 1.' },
           { name: 'pageSize', in: 'query', type: 'number', desc: 'Rows per page. Defaults to 25, capped at 200.' },
@@ -575,7 +575,7 @@ export const sections: readonly Section[] = [
           { name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
         ],
         response:
-          '{\n  "success": true,\n  "obj": {\n    "items": [\n      {\n        "email": "[email protected]",\n        "subId": "abcd1234",\n        "enable": true,\n        "totalGB": 53687091200,\n        "expiryTime": 1735689600000,\n        "limitIp": 0,\n        "reset": 0,\n        "inboundIds": [3, 5],\n        "traffic": { "up": 1024, "down": 4096, "enable": true },\n        "createdAt": 1735000000000,\n        "updatedAt": 1735100000000\n      }\n    ],\n    "total": 2000,\n    "filtered": 47,\n    "page": 1,\n    "pageSize": 25,\n    "summary": {\n      "total": 2000,\n      "active": 1850,\n      "online": ["[email protected]"],\n      "depleted": [],\n      "expiring": [],\n      "deactive": []\n    }\n  }\n}',
+          '{\n  "success": true,\n  "obj": {\n    "items": [\n      {\n        "email": "[email protected]",\n        "subId": "abcd1234",\n        "enable": true,\n        "totalGB": 53687091200,\n        "expiryTime": 1735689600000,\n        "limitIp": 0,\n        "reset": 0,\n        "inboundIds": [3, 5],\n        "traffic": { "up": 1024, "down": 4096, "enable": true },\n        "createdAt": 1735000000000,\n        "updatedAt": 1735100000000\n      }\n    ],\n    "total": 2000,\n    "filtered": 47,\n    "page": 1,\n    "pageSize": 25,\n    "summary": {\n      "total": 2000,\n      "active": 1850,\n      "onlineCount": 1,\n      "depletedCount": 0,\n      "expiringCount": 0,\n      "deactiveCount": 150,\n      "online": ["[email protected]"],\n      "depleted": [],\n      "expiring": [],\n      "deactive": ["[email protected]"]\n    }\n  }\n}',
       },
       {
         method: 'GET',

+ 1 - 1
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -56,7 +56,7 @@ export default function ClientBulkAddModal({
 }: ClientBulkAddModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
-  const { bulkCreate } = useClients();
+  const { bulkCreate } = useClients({ list: false });
 
   const methods = useForm<ClientBulkAddFormValues>({ defaultValues: EMPTY });
   const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });

+ 7 - 0
frontend/src/pages/clients/ClientsPage.css

@@ -16,6 +16,13 @@
   white-space: nowrap;
 }
 
+.client-email-more {
+  margin-top: 4px;
+  padding-top: 4px;
+  border-top: 1px solid var(--ant-color-border-secondary, rgba(128, 128, 128, 0.2));
+  opacity: 0.65;
+}
+
 .filter-bar {
   display: flex;
   flex-wrap: wrap;

+ 127 - 95
frontend/src/pages/clients/ClientsPage.tsx

@@ -1,4 +1,4 @@
-import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
+import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Badge,
@@ -16,7 +16,6 @@ import {
   Result,
   Row,
   Select,
-  Space,
   Spin,
   Statistic,
   Switch,
@@ -80,12 +79,14 @@ const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
 const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
 const TextModal = lazy(() => import('@/components/feedback/TextModal'));
 const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
+import { ClientInboundChips, ClientRowActions } from './RowCells';
 import { emptyFilters, activeFilterCount } from './filters';
 import type { ClientFilters } from './filters';
 import './ClientsPage.css';
 
 const FILTER_STATE_KEY = 'clientsFilterState';
 const DISABLED_PAGE_SIZE = 200;
+const DEFAULT_TABLE_PAGE_SIZE = 25;
 
 function UngroupIcon() {
   return (
@@ -126,12 +127,29 @@ function UngroupIcon() {
   );
 }
 
+// The server sends exact counters but caps the email arrays behind them, so a
+// panel with thousands of depleted clients neither ships nor renders them all.
+// The trailing chip reports what the popover left out.
+function ClientEmailList({ emails, total }: { emails: string[]; total: number }) {
+  const hidden = total - emails.length;
+  return (
+    <div className="client-email-list">
+      {emails.map((e) => <div key={e}>{e}</div>)}
+      {hidden > 0 && <div className="client-email-more">+{hidden}</div>}
+    </div>
+  );
+}
+
 type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
 
 interface PersistedFilterState {
   searchKey: string;
   filters: ClientFilters;
   sort: string;
+  // The page size resolved on the previous visit. Without it the first list
+  // request has to wait for /setting/defaultSettings just to learn how many rows
+  // to ask for, which serialises two round trips on every load.
+  pageSize: number | null;
 }
 
 const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
@@ -147,6 +165,9 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
   tunnel: 'orange',
 };
 const INBOUND_CHIP_LIMIT = 1;
+// A shared empty array keeps the memoised chip cell from seeing a fresh prop for
+// every unattached client on every render.
+const EMPTY_INBOUND_IDS: number[] = [];
 
 function readFilterState(): PersistedFilterState {
   try {
@@ -164,9 +185,10 @@ function readFilterState(): PersistedFilterState {
         groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
       },
       sort: typeof raw.sort === 'string' ? raw.sort : '',
+      pageSize: typeof raw.pageSize === 'number' && raw.pageSize > 0 ? raw.pageSize : null,
     };
   } catch {
-    return { searchKey: '', filters: emptyFilters(), sort: '' };
+    return { searchKey: '', filters: emptyFilters(), sort: '', pageSize: null };
   }
 }
 
@@ -208,8 +230,8 @@ export default function ClientsPage() {
     summary,
     allGroups,
     setQuery,
-    inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
-    tgBotEnable, expireDiff, trafficDiff, pageSize,
+    inbounds, onlines, transitioning, fetched, fetchError, subSettings,
+    tgBotEnable, expireDiff, trafficDiff, pageSize, settingsReady,
     create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
     resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
     clientSpeed,
@@ -265,14 +287,31 @@ export default function ClientsPage() {
   const [sortColumn, setSortColumn] = useState<string | null>(initialSort.column);
   const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(initialSort.order);
   const [currentPage, setCurrentPage] = useState(1);
-  const [tablePageSize, setTablePageSize] = useState(25);
+  // Derived, not mirrored into state by an effect: an effect lags one render
+  // behind the settings arriving, and that lag is what made the page fetch the
+  // list once with the placeholder size and again with the real one.
+  const [pageSizeChoice, setPageSizeChoice] = useState<number | null>(null);
+  const settingsPageSize = settingsReady ? (pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE) : null;
+  // Last visit's resolved size stands in until the settings land, so the list
+  // request goes out with the page mount instead of queueing behind them. If the
+  // admin has since changed the setting the authoritative value replaces it and
+  // costs one refetch — only on the load that follows the change. Null means
+  // nothing is known yet, which is the one case worth waiting for.
+  const resolvedPageSize = pageSizeChoice ?? settingsPageSize ?? initial.pageSize;
+  const tablePageSize = resolvedPageSize ?? DEFAULT_TABLE_PAGE_SIZE;
   // 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({ searchKey, filters, sort: sortValueFor(sortColumn, sortOrder) }));
-  }, [searchKey, filters, sortColumn, sortOrder]);
+    localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
+      searchKey,
+      filters,
+      sort: sortValueFor(sortColumn, sortOrder),
+      // Only ever persist a size we actually resolved, never the render fallback.
+      pageSize: resolvedPageSize,
+    }));
+  }, [searchKey, filters, sortColumn, sortOrder, resolvedPageSize]);
 
   useEffect(() => {
     const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
@@ -303,6 +342,10 @@ export default function ClientsPage() {
   }, [filters.nodeIds, filters.inboundIds, inbounds]);
 
   useEffect(() => {
+    // With no remembered size and no settings yet, any query we build would be a
+    // guess, and issuing it costs a full server round trip that is thrown away as
+    // soon as the real size arrives.
+    if (resolvedPageSize === null) return;
     setQuery({
       page: currentPage,
       pageSize: tablePageSize,
@@ -321,13 +364,21 @@ export default function ClientsPage() {
       sort: sortColumn || undefined,
       order: sortOrder || undefined,
     });
-  }, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
+  }, [setQuery, resolvedPageSize, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
 
   const activeCount = activeFilterCount(filters);
 
-  useEffect(() => {
-    setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
-  }, [pageSize]);
+  // Row handlers take an email and look the row up here at call time. Keying
+  // them on the record object instead would defeat the memoised cells: every
+  // traffic push replaces the row object of every client whose counters moved,
+  // so the memo would miss on exactly the rows that are busy. Reading through
+  // the ref also means a modal opened mid-poll shows current usage.
+  const rowsByEmail = useRef(new Map<string, ClientRecord>());
+  rowsByEmail.current = useMemo(() => {
+    const map = new Map<string, ClientRecord>();
+    for (const c of clients) map.set(c.email, c);
+    return map;
+  }, [clients]);
 
   const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
   const inboundsById = useMemo(() => {
@@ -454,7 +505,9 @@ export default function ClientsPage() {
     setFormOpen(true);
   }
 
-  async function onEdit(row: ClientRecord) {
+  const onEdit = useCallback(async (email: string) => {
+    const row = rowsByEmail.current.get(email);
+    if (!row) return;
     setFormMode('edit');
     // Paged list omits per-client secrets to keep the row payload tiny;
     // edit needs them, so fetch the full record first.
@@ -465,9 +518,11 @@ export default function ClientsPage() {
     setEditingAttachedIds([...ids]);
     setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
     setFormOpen(true);
-  }
+  }, [hydrate]);
 
-  function onDelete(row: ClientRecord) {
+  const onDelete = useCallback((email: string) => {
+    const row = rowsByEmail.current.get(email);
+    if (!row) return;
     modal.confirm({
       title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
       content: t('pages.clients.deleteConfirmContent'),
@@ -479,9 +534,10 @@ export default function ClientsPage() {
         if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
       },
     });
-  }
+  }, [modal, t, remove, messageApi]);
 
-  function onResetTraffic(row: ClientRecord) {
+  const onResetTraffic = useCallback((email: string) => {
+    const row = rowsByEmail.current.get(email);
     if (!row?.email) {
       messageApi.warning(t('pages.clients.resetNotPossible'));
       return;
@@ -496,19 +552,33 @@ export default function ClientsPage() {
         if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
       },
     });
-  }
+  }, [modal, t, resetTraffic, messageApi]);
 
-  async function onShowInfo(row: ClientRecord) {
+  const onShowInfo = useCallback(async (email: string) => {
+    const row = rowsByEmail.current.get(email);
+    if (!row) return;
     const full = await hydrate(row.email);
     setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
     setInfoOpen(true);
-  }
+  }, [hydrate]);
 
-  async function onShowQr(row: ClientRecord) {
+  const onShowQr = useCallback(async (email: string) => {
+    const row = rowsByEmail.current.get(email);
+    if (!row) return;
     const full = await hydrate(row.email);
     setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
     setQrOpen(true);
-  }
+  }, [hydrate]);
+
+  const [refreshing, setRefreshing] = useState(false);
+  const onRefreshClick = useCallback(async () => {
+    setRefreshing(true);
+    try {
+      await refresh();
+    } finally {
+      setRefreshing(false);
+    }
+  }, [refresh]);
 
   const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
     setTextTitle(opts.title);
@@ -743,7 +813,7 @@ export default function ClientsPage() {
 
   const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
     if (pag?.current) setCurrentPage(pag.current);
-    if (pag?.pageSize) setTablePageSize(pag.pageSize);
+    if (pag?.pageSize) setPageSizeChoice(pag.pageSize);
   };
 
   const columns = useMemo<ColumnsType<ClientRecord>>(() => [
@@ -752,23 +822,14 @@ export default function ClientsPage() {
       key: 'actions',
       width: 200,
       render: (_v, record) => (
-        <Space size={4}>
-          <Tooltip title={t('pages.clients.qrCode')}>
-            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} onClick={() => onShowQr(record)} />
-          </Tooltip>
-          <Tooltip title={t('pages.clients.clientInfo')}>
-            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} aria-label={t('pages.clients.clientInfo')} onClick={() => onShowInfo(record)} />
-          </Tooltip>
-          <Tooltip title={t('pages.inbounds.resetTraffic')}>
-            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} aria-label={t('pages.inbounds.resetTraffic')} onClick={() => onResetTraffic(record)} />
-          </Tooltip>
-          <Tooltip title={t('edit')}>
-            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(record)} />
-          </Tooltip>
-          <Tooltip title={t('delete')}>
-            <Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(record)} />
-          </Tooltip>
-        </Space>
+        <ClientRowActions
+          email={record.email}
+          onShowQr={onShowQr}
+          onShowInfo={onShowInfo}
+          onResetTraffic={onResetTraffic}
+          onEdit={onEdit}
+          onDelete={onDelete}
+        />
       ),
     },
     {
@@ -850,42 +911,13 @@ export default function ClientsPage() {
       key: 'inboundIds',
       width: 170,
       render: (_v, record) => {
-        const ids = record.inboundIds || [];
-        if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
-        const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
-        const overflow = ids.slice(INBOUND_CHIP_LIMIT);
-        const chip = (id: number, compact: boolean) => {
-          const ib = inboundsById[id];
-          const proto = (ib?.protocol || '').toLowerCase();
-          const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
-          const compactLabel = formatInboundLabel(ib?.tag, ib?.remark);
-          return (
-            <Tooltip key={id} title={inboundLabel(id)}>
-              <Tag color={color} style={{ margin: 2 }}>
-                {compact ? compactLabel : inboundLabel(id)}
-              </Tag>
-            </Tooltip>
-          );
-        };
         return (
-          <>
-            {visible.map((id) => chip(id, true))}
-            {overflow.length > 0 && (
-              <Popover
-                trigger="click"
-                placement="bottomRight"
-                content={
-                  <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
-                    {overflow.map((id) => chip(id, false))}
-                  </div>
-                }
-              >
-                <Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
-                  +{overflow.length}
-                </Tag>
-              </Popover>
-            )}
-          </>
+          <ClientInboundChips
+            ids={record.inboundIds || EMPTY_INBOUND_IDS}
+            inboundsById={inboundsById}
+            protocolColors={INBOUND_PROTOCOL_COLORS}
+            chipLimit={INBOUND_CHIP_LIMIT}
+          />
         );
       },
     },
@@ -994,7 +1026,7 @@ export default function ClientsPage() {
                   status="error"
                   title={t('somethingWentWrong')}
                   subTitle={fetchError}
-                  extra={<Button type="primary" loading={loading} onClick={refresh}>{t('refresh')}</Button>}
+                  extra={<Button type="primary" loading={refreshing} onClick={onRefreshClick}>{t('refresh')}</Button>}
                 />
               ) : (
                 <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
@@ -1007,37 +1039,37 @@ export default function ClientsPage() {
                         <Col xs={12} sm={8} md={4}>
                           <Popover
                             title={t('online')}
-                            open={summary.online.length ? undefined : false}
-                            content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
+                            open={summary.onlineCount ? undefined : false}
+                            content={<ClientEmailList emails={summary.online} total={summary.onlineCount} />}
                           >
-                            <Statistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
+                            <Statistic title={t('online')} value={String(summary.onlineCount)} prefix={<span className="dot dot-blue" />} />
                           </Popover>
                         </Col>
                         <Col xs={12} sm={8} md={4}>
                           <Popover
                             title={t('depleted')}
-                            open={summary.depleted.length ? undefined : false}
-                            content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
+                            open={summary.depletedCount ? undefined : false}
+                            content={<ClientEmailList emails={summary.depleted} total={summary.depletedCount} />}
                           >
-                            <Statistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
+                            <Statistic title={t('depleted')} value={String(summary.depletedCount)} prefix={<span className="dot dot-red" />} />
                           </Popover>
                         </Col>
                         <Col xs={12} sm={8} md={4}>
                           <Popover
                             title={t('depletingSoon')}
-                            open={summary.expiring.length ? undefined : false}
-                            content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
+                            open={summary.expiringCount ? undefined : false}
+                            content={<ClientEmailList emails={summary.expiring} total={summary.expiringCount} />}
                           >
-                            <Statistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
+                            <Statistic title={t('depletingSoon')} value={String(summary.expiringCount)} prefix={<span className="dot dot-orange" />} />
                           </Popover>
                         </Col>
                         <Col xs={12} sm={8} md={4}>
                           <Popover
                             title={t('disabled')}
-                            open={summary.deactive.length ? undefined : false}
-                            content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
+                            open={summary.deactiveCount ? undefined : false}
+                            content={<ClientEmailList emails={summary.deactive} total={summary.deactiveCount} />}
                           >
-                            <Statistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
+                            <Statistic title={t('disabled')} value={String(summary.deactiveCount)} prefix={<span className="dot dot-gray" />} />
                           </Popover>
                         </Col>
                         <Col xs={12} sm={8} md={4}>
@@ -1364,7 +1396,7 @@ export default function ClientsPage() {
                                   showTotal={(n) => `${n}`}
                                   onChange={(p, s) => {
                                     setCurrentPage(p);
-                                    if (s && s !== tablePageSize) setTablePageSize(s);
+                                    if (s && s !== tablePageSize) setPageSizeChoice(s);
                                   }}
                                 />
                               </div>
@@ -1391,8 +1423,8 @@ export default function ClientsPage() {
                                           role="button"
                                           tabIndex={0}
                                           aria-label={t('pages.clients.clientInfo')}
-                                          onClick={() => onShowInfo(row)}
-                                          onKeyDown={activateOnKey(() => onShowInfo(row))}
+                                          onClick={() => onShowInfo(row.email)}
+                                          onKeyDown={activateOnKey(() => onShowInfo(row.email))}
                                         />
                                       </Tooltip>
                                       <Switch
@@ -1409,23 +1441,23 @@ export default function ClientsPage() {
                                             {
                                               key: 'qr',
                                               label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
-                                              onClick: () => onShowQr(row),
+                                              onClick: () => onShowQr(row.email),
                                             },
                                             {
                                               key: 'reset',
                                               label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
-                                              onClick: () => onResetTraffic(row),
+                                              onClick: () => onResetTraffic(row.email),
                                             },
                                             {
                                               key: 'edit',
                                               label: <><EditOutlined /> {t('edit')}</>,
-                                              onClick: () => onEdit(row),
+                                              onClick: () => onEdit(row.email),
                                             },
                                             {
                                               key: 'delete',
                                               danger: true,
                                               label: <><DeleteOutlined /> {t('delete')}</>,
-                                              onClick: () => onDelete(row),
+                                              onClick: () => onDelete(row.email),
                                             },
                                           ],
                                         }}

+ 154 - 0
frontend/src/pages/clients/RowCells.tsx

@@ -0,0 +1,154 @@
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Popover, Space, Tag, Tooltip } from 'antd';
+import {
+  DeleteOutlined,
+  EditOutlined,
+  InfoCircleOutlined,
+  QrcodeOutlined,
+  RetweetOutlined,
+} from '@ant-design/icons';
+
+import { formatInboundLabel } from '@/lib/inbounds/label';
+import type { InboundOption } from '@/hooks/useClients';
+
+const ICON_BUTTON_STYLE = { fontSize: 16 } as const;
+
+interface ClientRowActionsProps {
+  email: string;
+  onShowQr: (email: string) => void;
+  onShowInfo: (email: string) => void;
+  onResetTraffic: (email: string) => void;
+  onEdit: (email: string) => void;
+  onDelete: (email: string) => void;
+}
+
+// Five Tooltip-wrapped buttons per row, none of which depend on traffic. Left
+// inline they re-ran rc-tooltip's alignment machinery for every visible row on
+// every traffic push — 125 Tooltips on a 25-row page, five seconds apart.
+// Keyed on the email rather than the row object, because a push replaces the row
+// object of every client whose counters moved; the page resolves the live row.
+export const ClientRowActions = memo(function ClientRowActions({
+  email,
+  onShowQr,
+  onShowInfo,
+  onResetTraffic,
+  onEdit,
+  onDelete,
+}: ClientRowActionsProps) {
+  const { t } = useTranslation();
+  return (
+    <Space size={4}>
+      <Tooltip title={t('pages.clients.qrCode')}>
+        <Button
+          size="small"
+          type="text"
+          style={ICON_BUTTON_STYLE}
+          icon={<QrcodeOutlined />}
+          aria-label={t('pages.clients.qrCode')}
+          onClick={() => onShowQr(email)}
+        />
+      </Tooltip>
+      <Tooltip title={t('pages.clients.clientInfo')}>
+        <Button
+          size="small"
+          type="text"
+          style={ICON_BUTTON_STYLE}
+          icon={<InfoCircleOutlined />}
+          aria-label={t('pages.clients.clientInfo')}
+          onClick={() => onShowInfo(email)}
+        />
+      </Tooltip>
+      <Tooltip title={t('pages.inbounds.resetTraffic')}>
+        <Button
+          size="small"
+          type="text"
+          style={ICON_BUTTON_STYLE}
+          icon={<RetweetOutlined />}
+          aria-label={t('pages.inbounds.resetTraffic')}
+          onClick={() => onResetTraffic(email)}
+        />
+      </Tooltip>
+      <Tooltip title={t('edit')}>
+        <Button
+          size="small"
+          type="text"
+          style={ICON_BUTTON_STYLE}
+          icon={<EditOutlined />}
+          aria-label={t('edit')}
+          onClick={() => onEdit(email)}
+        />
+      </Tooltip>
+      <Tooltip title={t('delete')}>
+        <Button
+          size="small"
+          type="text"
+          danger
+          style={ICON_BUTTON_STYLE}
+          icon={<DeleteOutlined />}
+          aria-label={t('delete')}
+          onClick={() => onDelete(email)}
+        />
+      </Tooltip>
+    </Space>
+  );
+});
+
+const CHIP_STYLE = { margin: 2 } as const;
+const OVERFLOW_CHIP_STYLE = { margin: 2, cursor: 'pointer' } as const;
+const OVERFLOW_LIST_STYLE = {
+  display: 'flex',
+  flexDirection: 'column' as const,
+  gap: 4,
+  maxWidth: 280,
+  maxHeight: 280,
+  overflowY: 'auto' as const,
+};
+
+interface ClientInboundChipsProps {
+  ids: number[];
+  inboundsById: Record<number, InboundOption>;
+  protocolColors: Record<string, string>;
+  chipLimit: number;
+}
+
+// Attachments never change on a traffic push either, so the same memoisation
+// applies: one Tooltip per visible chip plus a Popover for the overflow.
+export const ClientInboundChips = memo(function ClientInboundChips({
+  ids,
+  inboundsById,
+  protocolColors,
+  chipLimit,
+}: ClientInboundChipsProps) {
+  if (ids.length === 0) return <span className="cell-empty">—</span>;
+
+  const label = (id: number) => {
+    const ib = inboundsById[id];
+    return formatInboundLabel(ib?.tag, ib?.remark);
+  };
+  const chip = (id: number) => {
+    const proto = (inboundsById[id]?.protocol || '').toLowerCase();
+    return (
+      <Tooltip key={id} title={label(id)}>
+        <Tag color={protocolColors[proto] ?? 'default'} style={CHIP_STYLE}>{label(id)}</Tag>
+      </Tooltip>
+    );
+  };
+
+  const visible = ids.slice(0, chipLimit);
+  const overflow = ids.slice(chipLimit);
+  return (
+    <>
+      {visible.map(chip)}
+      {overflow.length > 0 && (
+        <Popover
+          trigger="click"
+          placement="bottomRight"
+          content={<div style={OVERFLOW_LIST_STYLE}>{overflow.map(chip)}</div>}
+        >
+          <Tag color="default" style={OVERFLOW_CHIP_STYLE}>+{overflow.length}</Tag>
+        </Popover>
+      )}
+    </>
+  );
+});

+ 1 - 1
frontend/src/pages/groups/GroupsPage.tsx

@@ -93,7 +93,7 @@ export default function GroupsPage() {
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
   const queryClient = useQueryClient();
 
-  const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients();
+  const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({ list: false });
 
   const groupsQuery = useQuery({
     queryKey: keys.clients.groups(),

+ 6 - 0
frontend/src/schemas/client.ts

@@ -68,9 +68,15 @@ export const InboundOptionSchema = z.object({
 
 export const InboundOptionsSchema = z.array(InboundOptionSchema);
 
+// The *Count fields are exact; the email arrays stop at the server's cap and
+// only feed the hover popovers, so never derive a counter from their length.
 export const ClientsSummarySchema = z.object({
   total: z.number(),
   active: z.number(),
+  onlineCount: z.number().optional().default(0),
+  depletedCount: z.number().optional().default(0),
+  expiringCount: z.number().optional().default(0),
+  deactiveCount: z.number().optional().default(0),
   online: nullableStringArray,
   depleted: nullableStringArray,
   expiring: nullableStringArray,

+ 127 - 0
frontend/src/test/clients-query-gating.test.tsx

@@ -0,0 +1,127 @@
+import type { ReactNode } from 'react';
+import { renderHook, waitFor, act } from '@testing-library/react';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { useClients } from '@/hooks/useClients';
+import { makeTestQueryClient } from '@/test/test-utils';
+import { HttpUtil, Msg } from '@/utils';
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+const emptyPage = {
+  items: [],
+  total: 0,
+  filtered: 0,
+  page: 1,
+  pageSize: 25,
+  groups: [],
+  summary: {
+    total: 0,
+    active: 0,
+    onlineCount: 0,
+    depletedCount: 0,
+    expiringCount: 0,
+    deactiveCount: 0,
+    online: [],
+    depleted: [],
+    expiring: [],
+    deactive: [],
+  },
+};
+
+function mockPanel(defaults: Record<string, unknown>) {
+  const pagedUrls: string[] = [];
+  vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
+    if (url.includes('/clients/list/paged')) {
+      pagedUrls.push(url);
+      return new Msg(true, '', emptyPage);
+    }
+    if (url.includes('/inbounds/options')) return new Msg(true, '', []);
+    return new Msg(true, '', null);
+  });
+  vi.spyOn(HttpUtil, 'post').mockImplementation(async (url: string) => {
+    if (url.includes('/setting/defaultSettings')) return new Msg(true, '', defaults);
+    if (url.includes('/clients/onlines')) return new Msg(true, '', []);
+    return new Msg(true, '', null);
+  });
+  return pagedUrls;
+}
+
+function wrapperFor() {
+  const queryClient = makeTestQueryClient();
+  return ({ children }: { children: ReactNode }) => (
+    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+  );
+}
+
+describe('useClients query gating', () => {
+  it('does not fetch the list until the page supplies a query', async () => {
+    const pagedUrls = mockPanel({ pageSize: 25 });
+    const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
+
+    await waitFor(() => expect(result.current.settingsReady).toBe(true));
+    // The page has not called setQuery yet, so nothing should have gone out —
+    // this is what used to cost a thrown-away round trip on every page load.
+    expect(pagedUrls).toEqual([]);
+    expect(result.current.fetched).toBe(false);
+  });
+
+  it('issues exactly one request for a page load that settles on one query', async () => {
+    const pagedUrls = mockPanel({ pageSize: 50 });
+    const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
+
+    await waitFor(() => expect(result.current.settingsReady).toBe(true));
+    act(() => {
+      result.current.setQuery({ page: 1, pageSize: 50, sort: 'createdAt', order: 'ascend' });
+    });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    expect(pagedUrls).toHaveLength(1);
+    expect(pagedUrls[0]).toContain('pageSize=50');
+    expect(pagedUrls[0]).toContain('sort=createdAt');
+  });
+
+  it('fetches as soon as a query arrives, without waiting for the settings', async () => {
+    // The page remembers the previous visit's page size in localStorage, so on a
+    // return visit it can supply a query on the first render. The hook must not
+    // hold that back behind /setting/defaultSettings, or the two round trips
+    // serialise and the list lands ~160ms later than it needs to.
+    const pagedUrls = mockPanel({ pageSize: 25 });
+    const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
+
+    act(() => {
+      result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
+    });
+    await waitFor(() => expect(pagedUrls).toHaveLength(1));
+  });
+
+  it('reports settingsReady even when the settings request fails, so the page can still render', async () => {
+    vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', emptyPage));
+    vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(false, 'boom', null));
+    const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
+
+    await waitFor(() => expect(result.current.settingsReady).toBe(true));
+  });
+
+  it('skips the list, options and onlines queries for mutation-only callers', async () => {
+    const pagedUrls = mockPanel({ pageSize: 25 });
+    const postSpy = vi.mocked(HttpUtil.post);
+    const { result } = renderHook(() => useClients({ list: false }), { wrapper: wrapperFor() });
+
+    await waitFor(() => expect(result.current.settingsReady).toBe(true));
+    act(() => {
+      result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
+    });
+
+    await waitFor(() => expect(result.current.settingsReady).toBe(true));
+    expect(pagedUrls).toEqual([]);
+    // subSettings still needs defaultSettings; onlines must not be polled.
+    const posted = postSpy.mock.calls.map((c) => String(c[0]));
+    expect(posted.some((u) => u.includes('/setting/defaultSettings'))).toBe(true);
+    expect(posted.some((u) => u.includes('/clients/onlines'))).toBe(false);
+    expect(vi.mocked(HttpUtil.get).mock.calls.map((c) => String(c[0]))).toEqual([]);
+  });
+});

+ 117 - 0
frontend/src/test/clients-row-cells-memo.test.tsx

@@ -0,0 +1,117 @@
+import { useState } from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { ClientInboundChips, ClientRowActions } from '@/pages/clients/RowCells';
+import type { InboundOption } from '@/hooks/useClients';
+
+const PROTOCOL_COLORS = { vless: 'blue', trojan: 'volcano' };
+
+// Counts how often the cell reads the inbound map, which happens once per chip
+// per render. A traffic push re-renders the row, so if the cell is not memoised
+// this climbs every five seconds for every visible row.
+function countingInboundMap(source: Record<number, InboundOption>) {
+  const reads = { count: 0 };
+  const proxy = new Proxy(source, {
+    get(target, key) {
+      if (typeof key === 'string' && /^\d+$/.test(key)) reads.count += 1;
+      return target[key as unknown as number];
+    },
+  });
+  return { proxy, reads };
+}
+
+const INBOUNDS: Record<number, InboundOption> = {
+  1: { id: 1, tag: 'in-vless', remark: 'DE', protocol: 'vless' },
+  2: { id: 2, tag: 'in-trojan', remark: 'NL', protocol: 'trojan' },
+};
+
+function Harness({ children }: { children: (bump: () => void) => React.ReactNode }) {
+  const [, setTick] = useState(0);
+  return <>{children(() => setTick((n) => n + 1))}</>;
+}
+
+describe('clients table row cells', () => {
+  it('does not re-render the inbound chips when the row re-renders with the same attachments', async () => {
+    const { proxy, reads } = countingInboundMap(INBOUNDS);
+    const ids = [1, 2];
+    let bump: () => void = () => {};
+
+    render(
+      <Harness>
+        {(doBump) => {
+          bump = doBump;
+          return (
+            <ClientInboundChips ids={ids} inboundsById={proxy} protocolColors={PROTOCOL_COLORS} chipLimit={1} />
+          );
+        }}
+      </Harness>,
+    );
+
+    const afterFirstRender = reads.count;
+    expect(afterFirstRender).toBeGreaterThan(0);
+
+    // Three simulated traffic pushes: the parent re-renders, the props do not change.
+    for (let i = 0; i < 3; i++) bump();
+    await Promise.resolve();
+
+    expect(reads.count).toBe(afterFirstRender);
+  });
+
+  it('re-renders the chips when the attachments actually change', async () => {
+    const { proxy, reads } = countingInboundMap(INBOUNDS);
+
+    function Swapper() {
+      const [ids, setIds] = useState<number[]>([1]);
+      return (
+        <>
+          <button type="button" onClick={() => setIds([1, 2])}>swap</button>
+          <ClientInboundChips ids={ids} inboundsById={proxy} protocolColors={PROTOCOL_COLORS} chipLimit={1} />
+        </>
+      );
+    }
+    render(<Swapper />);
+    const before = reads.count;
+    await userEvent.click(screen.getByRole('button', { name: 'swap' }));
+    expect(reads.count).toBeGreaterThan(before);
+  });
+
+  it('keeps the row actions wired to the right client across re-renders', async () => {
+    const onShowQr = vi.fn();
+    const onEdit = vi.fn();
+    const noop = vi.fn();
+    let bump: () => void = () => {};
+
+    render(
+      <Harness>
+        {(doBump) => {
+          bump = doBump;
+          return (
+            <ClientRowActions
+              email="alice@x"
+              onShowQr={onShowQr}
+              onShowInfo={noop}
+              onResetTraffic={noop}
+              onEdit={onEdit}
+              onDelete={noop}
+            />
+          );
+        }}
+      </Harness>,
+    );
+
+    for (let i = 0; i < 3; i++) bump();
+
+    // Queried by position rather than label: the suite loads the real en-US
+    // bundle, so the aria-labels are translated strings, not keys. Order is
+    // QR, info, reset traffic, edit, delete.
+    const buttons = screen.getAllByRole('button');
+    expect(buttons).toHaveLength(5);
+    await userEvent.click(buttons[0]);
+    await userEvent.click(buttons[3]);
+
+    expect(onShowQr).toHaveBeenCalledExactlyOnceWith('alice@x');
+    expect(onEdit).toHaveBeenCalledExactlyOnceWith('alice@x');
+  });
+});

+ 58 - 2
frontend/src/test/clients-summary.test.ts

@@ -1,6 +1,6 @@
 import { describe, it, expect } from 'vitest';
 
-import { computeClientsSummary, pickClientsSummary } from '@/hooks/useClients';
+import { computeClientsSummary, pickClientsSummary, sameSpeedMap, sameSummaryInputs } from '@/hooks/useClients';
 import type { ClientTraffic, ClientsSummary } from '@/schemas/client';
 
 // Parity with web/service/client.go buildClientsSummary: the same client must
@@ -42,6 +42,24 @@ describe('computeClientsSummary', () => {
     expect(s.active).toBe(2); // online@x + offline@x
   });
 
+  it('reports a counter alongside every bucket list', () => {
+    const stats: Row[] = [
+      row({ email: 'online@x', enable: true }),
+      row({ email: 'disabled@x', enable: false }),
+      row({ email: 'exhausted@x', enable: true, total: 1 * GB, up: 1 * GB }),
+      row({ email: 'nearlimit@x', enable: true, total: 10 * GB, up: 9.9 * GB }),
+    ];
+    const s = computeClientsSummary(stats, new Set(['online@x']), 3 * DAY, 1 * GB);
+
+    // The server caps its lists but never its counters; the live recompute has
+    // both, so the summary card reads the same either way.
+    expect(s.onlineCount).toBe(s.online.length);
+    expect(s.depletedCount).toBe(s.depleted.length);
+    expect(s.expiringCount).toBe(s.expiring.length);
+    expect(s.deactiveCount).toBe(s.deactive.length);
+    expect(s.active + s.depletedCount + s.expiringCount + s.deactiveCount).toBe(s.total);
+  });
+
   it('depleted wins over disabled and over online', () => {
     const stats: Row[] = [
       row({ email: 'a@x', enable: false, total: 1 * GB, up: 2 * GB }),
@@ -63,7 +81,9 @@ describe('computeClientsSummary', () => {
 
 describe('pickClientsSummary', () => {
   const serverSummary: ClientsSummary = {
-    total: 67, active: 58, online: [], depleted: [], expiring: [], deactive: [],
+    total: 67, active: 58,
+    onlineCount: 0, depletedCount: 4, expiringCount: 3, deactiveCount: 2,
+    online: [], depleted: [], expiring: [], deactive: [],
   };
 
   it('keeps the server summary when the snapshot is short of the server total (#6102)', () => {
@@ -84,3 +104,39 @@ describe('pickClientsSummary', () => {
     expect(s).toEqual(serverSummary);
   });
 });
+
+describe('websocket payload identity preservation', () => {
+  const speed = (up: number, down: number) => ({ up, down });
+
+  it('treats an unchanged speed map as unchanged', () => {
+    const a = { 'a@x': speed(1, 2), 'b@x': speed(3, 4) };
+    expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 4) })).toBe(true);
+    expect(sameSpeedMap(a, { 'a@x': speed(1, 2) })).toBe(false);
+    expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 5) })).toBe(false);
+    expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'c@x': speed(3, 4) })).toBe(false);
+    expect(sameSpeedMap({}, {})).toBe(true);
+  });
+
+  it('compares exactly the fields the summary reads, and ignores lastOnline', () => {
+    const base: Row[] = [row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99 })];
+
+    // lastOnline moves for every online client on every push and no counter
+    // depends on it, so it must not force a new snapshot.
+    const onlyLastOnlineMoved: Row[] = [
+      row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, lastOnline: 12345 }),
+    ];
+    expect(sameSummaryInputs(base, onlyLastOnlineMoved)).toBe(true);
+
+    for (const changed of [
+      row({ email: 'b@x', up: 1, down: 2, total: 10, expiryTime: 99 }),
+      row({ email: 'a@x', up: 2, down: 2, total: 10, expiryTime: 99 }),
+      row({ email: 'a@x', up: 1, down: 3, total: 10, expiryTime: 99 }),
+      row({ email: 'a@x', up: 1, down: 2, total: 11, expiryTime: 99 }),
+      row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 100 }),
+      row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, enable: false }),
+    ]) {
+      expect(sameSummaryInputs(base, [changed])).toBe(false);
+    }
+    expect(sameSummaryInputs(base, [])).toBe(false);
+  });
+});

+ 27 - 9
internal/web/job/xray_traffic_job.go

@@ -29,6 +29,31 @@ type XrayTrafficJob struct {
 // refetch for the rest.
 const clientStatsSnapshotMaxClients = 5000
 
+// splitMovedClientTraffics keeps the rows that actually moved bytes this poll,
+// alongside the active-email list and set derived from the same pass.
+//
+// Xray reports a row for every known email whether or not it transferred
+// anything, so on a large panel nearly every delta is zero. The database writes
+// and the external-API inform consume the full slice before this point; the
+// WebSocket frame only feeds the dashboard's live speed column, where an absent
+// row and a zero row render identically. Broadcasting just the movers keeps that
+// frame from growing with the client count — at 5k clients it was carrying about
+// a megabyte of zeros every five seconds.
+func splitMovedClientTraffics(clientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, []string, map[string]bool) {
+	moved := make([]*xray.ClientTraffic, 0, len(clientTraffics))
+	emails := make([]string, 0, len(clientTraffics))
+	active := make(map[string]bool, len(clientTraffics))
+	for _, ct := range clientTraffics {
+		if ct == nil || ct.Up+ct.Down <= 0 {
+			continue
+		}
+		moved = append(moved, ct)
+		emails = append(emails, ct.Email)
+		active[ct.Email] = true
+	}
+	return moved, emails, active
+}
+
 const externalInformTimeout = 3 * time.Second
 
 var externalInformClient = &fasthttp.Client{
@@ -88,14 +113,7 @@ func (j *XrayTrafficJob) Run() {
 	// than the shared last_online column, which remote-node syncs also bump
 	// and would otherwise make a client active only on a remote node appear
 	// online on local inbounds.
-	activeEmails := make([]string, 0, len(clientTraffics))
-	deltaActive := make(map[string]bool, len(clientTraffics))
-	for _, ct := range clientTraffics {
-		if ct != nil && ct.Up+ct.Down > 0 {
-			activeEmails = append(activeEmails, ct.Email)
-			deltaActive[ct.Email] = true
-		}
-	}
+	movedTraffics, activeEmails, deltaActive := splitMovedClientTraffics(clientTraffics)
 	// When the core supports the online-stats API, union in connection-based
 	// onlines. Neither signal alone covers everything: an idle-but-connected
 	// client moves no bytes between polls (the delta heuristic's blind spot),
@@ -179,7 +197,7 @@ func (j *XrayTrafficJob) Run() {
 	}
 	websocket.BroadcastTraffic(map[string]any{
 		"traffics":       traffics,
-		"clientTraffics": clientTraffics,
+		"clientTraffics": movedTraffics,
 		"onlineClients":  onlineClients,
 		"onlineByGuid":   j.inboundService.GetOnlineClientsByGuid(),
 		"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),

+ 59 - 0
internal/web/job/xray_traffic_job_broadcast_test.go

@@ -0,0 +1,59 @@
+package job
+
+import (
+	"slices"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestSplitMovedClientTraffics(t *testing.T) {
+	rows := []*xray.ClientTraffic{
+		{Email: "idle@x", Up: 0, Down: 0},
+		{Email: "up@x", Up: 1024, Down: 0},
+		{Email: "down@x", Up: 0, Down: 2048},
+		nil,
+		{Email: "both@x", Up: 512, Down: 512},
+		{Email: "alsoidle@x", Up: 0, Down: 0},
+	}
+
+	moved, emails, active := splitMovedClientTraffics(rows)
+
+	t.Run("only the rows that moved bytes are broadcast", func(t *testing.T) {
+		got := make([]string, 0, len(moved))
+		for _, ct := range moved {
+			got = append(got, ct.Email)
+		}
+		want := []string{"up@x", "down@x", "both@x"}
+		if !slices.Equal(got, want) {
+			t.Fatalf("moved = %v, want %v", got, want)
+		}
+	})
+
+	t.Run("the active list and set agree with the broadcast rows", func(t *testing.T) {
+		want := []string{"up@x", "down@x", "both@x"}
+		if !slices.Equal(emails, want) {
+			t.Fatalf("activeEmails = %v, want %v", emails, want)
+		}
+		if len(active) != len(want) {
+			t.Fatalf("deltaActive has %d entries, want %d", len(active), len(want))
+		}
+		for _, e := range want {
+			if !active[e] {
+				t.Fatalf("deltaActive missing %q", e)
+			}
+		}
+		if active["idle@x"] {
+			t.Fatal("an idle client must not count as active")
+		}
+	})
+
+	t.Run("an all-idle poll broadcasts nothing", func(t *testing.T) {
+		moved, emails, active := splitMovedClientTraffics([]*xray.ClientTraffic{
+			{Email: "a@x"}, {Email: "b@x"},
+		})
+		if len(moved) != 0 || len(emails) != 0 || len(active) != 0 {
+			t.Fatalf("expected an empty split, got %d/%d/%d", len(moved), len(emails), len(active))
+		}
+	})
+}

+ 481 - 394
internal/web/service/client_paging.go

@@ -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)
-	})
-}

+ 624 - 0
internal/web/service/client_paging_test.go

@@ -0,0 +1,624 @@
+package service
+
+import (
+	"slices"
+	"strconv"
+	"testing"
+	"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"
+)
+
+const (
+	pagingDay = int64(86400000)
+	pagingGB  = int64(1) << 30
+)
+
+type pagingSeed struct {
+	email      string
+	enable     bool
+	totalGB    int64
+	expiryTime int64
+	used       int64
+	subID      string
+	uuid       string
+	password   string
+	auth       string
+	comment    string
+	group      string
+	tgID       int64
+	reset      int
+	lastOnline int64
+	inbounds   []int
+}
+
+// seedPagingClients writes one vless and one trojan inbound plus a fixed client
+// set covering every bucket, sort key and search field ListPaged supports.
+// Returns "now" so the expectations can be phrased relative to it.
+func seedPagingClients(t *testing.T) (int64, []pagingSeed) {
+	t.Helper()
+	db := database.GetDB()
+	now := time.Now().UnixMilli()
+
+	vless := &model.Inbound{UserId: 1, Tag: "in-vless", Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
+	trojan := &model.Inbound{UserId: 1, Tag: "in-trojan", Enable: true, Port: 40002, Protocol: model.Trojan, Settings: `{"clients":[]}`}
+	for _, ib := range []*model.Inbound{vless, trojan} {
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create inbound %s: %v", ib.Tag, err)
+		}
+	}
+
+	seeds := []pagingSeed{
+		{email: "alpha@x", enable: true, totalGB: 0, expiryTime: 0, used: 5 * pagingGB, subID: "sub-alpha", uuid: "uuid-alpha", inbounds: []int{vless.Id}, lastOnline: now - 10*pagingDay},
+		{email: "bravo@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: pagingGB, password: "pw-bravo", inbounds: []int{vless.Id, trojan.Id}, lastOnline: now - pagingDay},
+		{email: "charlie@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10 * pagingGB, auth: "auth-charlie", inbounds: []int{trojan.Id}},
+		{email: "delta@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, used: pagingGB, inbounds: []int{vless.Id}},
+		{email: "echo@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, inbounds: []int{vless.Id}},
+		{email: "foxtrot@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, inbounds: []int{trojan.Id}},
+		{email: "golf@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 2*pagingDay, inbounds: []int{vless.Id}},
+		{email: "hotel@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10*pagingGB - pagingGB/2, inbounds: []int{vless.Id}},
+		{email: "india@x", enable: true, totalGB: 0, expiryTime: -5 * pagingDay, inbounds: []int{vless.Id}},
+		{email: "juliet@x", enable: true, comment: " vip customer ", group: "VIP", tgID: 555, reset: 7, inbounds: []int{vless.Id}},
+		{email: "kilo_1@x", enable: true, group: "vip", inbounds: nil},
+		{email: "kilo1@x", enable: true, inbounds: []int{trojan.Id}},
+	}
+
+	for i, s := range seeds {
+		rec := model.ClientRecord{
+			Email:      s.email,
+			SubID:      s.subID,
+			UUID:       s.uuid,
+			Password:   s.password,
+			Auth:       s.auth,
+			Comment:    s.comment,
+			Group:      s.group,
+			TgID:       s.tgID,
+			Reset:      s.reset,
+			Enable:     s.enable,
+			TotalGB:    s.totalGB,
+			ExpiryTime: s.expiryTime,
+			CreatedAt:  now - int64(len(seeds)-i)*pagingDay,
+			UpdatedAt:  now - int64(i)*pagingDay,
+		}
+		if err := db.Create(&rec).Error; err != nil {
+			t.Fatalf("create client %s: %v", s.email, err)
+		}
+		if !s.enable {
+			// clients.enable carries a `default:true` tag, so GORM leaves the
+			// zero value out of the INSERT and the column comes back true.
+			// Restate updated_at so the autoUpdateTime hook cannot reshuffle
+			// the sort fixtures.
+			if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).
+				Updates(map[string]any{"enable": false, "updated_at": rec.UpdatedAt}).Error; err != nil {
+				t.Fatalf("disable %s: %v", s.email, err)
+			}
+		}
+		traffic := xray.ClientTraffic{
+			Email:      s.email,
+			Enable:     s.enable,
+			Up:         s.used / 2,
+			Down:       s.used - s.used/2,
+			Total:      s.totalGB,
+			ExpiryTime: s.expiryTime,
+			LastOnline: s.lastOnline,
+		}
+		if err := db.Create(&traffic).Error; err != nil {
+			t.Fatalf("create traffic %s: %v", s.email, err)
+		}
+		for _, id := range s.inbounds {
+			if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: id}).Error; err != nil {
+				t.Fatalf("attach %s to %d: %v", s.email, id, err)
+			}
+		}
+	}
+	return now, seeds
+}
+
+func pagedEmails(items []ClientSlim) []string {
+	out := make([]string, 0, len(items))
+	for _, it := range items {
+		out = append(out, it.Email)
+	}
+	return out
+}
+
+func setupPagingServices(t *testing.T) (*ClientService, *InboundService, *SettingService) {
+	t.Helper()
+	setupBulkDB(t)
+	settingSvc := &SettingService{}
+	if err := settingSvc.setInt("expireDiff", 3); err != nil {
+		t.Fatalf("set expireDiff: %v", err)
+	}
+	if err := settingSvc.setInt("trafficDiff", 1); err != nil {
+		t.Fatalf("set trafficDiff: %v", err)
+	}
+	return &ClientService{}, &InboundService{}, settingSvc
+}
+
+func TestListPagedFilters(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	now, _ := seedPagingClients(t)
+
+	tests := []struct {
+		name   string
+		params ClientPageParams
+		want   []string
+	}{
+		{
+			name:   "no filter returns every client in id order",
+			params: ClientPageParams{PageSize: 50},
+			want:   []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
+		},
+		{
+			name:   "depleted bucket covers quota and expiry",
+			params: ClientPageParams{PageSize: 50, Filter: "depleted"},
+			want:   []string{"charlie@x", "delta@x", "foxtrot@x"},
+		},
+		{
+			name:   "deactive bucket is every disabled client",
+			params: ClientPageParams{PageSize: 50, Filter: "deactive"},
+			want:   []string{"echo@x", "foxtrot@x"},
+		},
+		{
+			name:   "expiring bucket covers near expiry and near quota",
+			params: ClientPageParams{PageSize: 50, Filter: "expiring"},
+			want:   []string{"golf@x", "hotel@x"},
+		},
+		{
+			name:   "active bucket keeps enabled clients that still have room",
+			params: ClientPageParams{PageSize: 50, Filter: "active"},
+			want:   []string{"alpha@x", "bravo@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
+		},
+		{
+			name:   "buckets are ORed",
+			params: ClientPageParams{PageSize: 50, Filter: "depleted,expiring"},
+			want:   []string{"charlie@x", "delta@x", "foxtrot@x", "golf@x", "hotel@x"},
+		},
+		{
+			name:   "unknown bucket keeps matching everything",
+			params: ClientPageParams{PageSize: 50, Filter: "nonsense"},
+			want:   []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
+		},
+		{
+			name:   "protocol filter follows the attachments",
+			params: ClientPageParams{PageSize: 50, Protocol: "trojan"},
+			want:   []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"},
+		},
+		{
+			name:   "inbound filter follows the attachments",
+			params: ClientPageParams{PageSize: 50, Inbound: "2"},
+			want:   []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"},
+		},
+		{
+			name:   "search matches the email",
+			params: ClientPageParams{PageSize: 50, Search: "KILO"},
+			want:   []string{"kilo_1@x", "kilo1@x"},
+		},
+		{
+			name:   "search treats LIKE wildcards literally",
+			params: ClientPageParams{PageSize: 50, Search: "kilo_1"},
+			want:   []string{"kilo_1@x"},
+		},
+		{
+			name:   "search matches the subId",
+			params: ClientPageParams{PageSize: 50, Search: "sub-alpha"},
+			want:   []string{"alpha@x"},
+		},
+		{
+			name:   "search matches the uuid",
+			params: ClientPageParams{PageSize: 50, Search: "uuid-alpha"},
+			want:   []string{"alpha@x"},
+		},
+		{
+			name:   "search matches the password",
+			params: ClientPageParams{PageSize: 50, Search: "pw-bravo"},
+			want:   []string{"bravo@x"},
+		},
+		{
+			name:   "search matches the auth",
+			params: ClientPageParams{PageSize: 50, Search: "auth-charlie"},
+			want:   []string{"charlie@x"},
+		},
+		{
+			name:   "search matches the comment",
+			params: ClientPageParams{PageSize: 50, Search: "vip customer"},
+			want:   []string{"juliet@x"},
+		},
+		{
+			name:   "search matches the telegram id",
+			params: ClientPageParams{PageSize: 50, Search: "555"},
+			want:   []string{"juliet@x"},
+		},
+		{
+			name:   "group filter is case insensitive",
+			params: ClientPageParams{PageSize: 50, Group: "vip"},
+			want:   []string{"juliet@x", "kilo_1@x"},
+		},
+		{
+			name:   "hasComment yes",
+			params: ClientPageParams{PageSize: 50, HasComment: "yes"},
+			want:   []string{"juliet@x"},
+		},
+		{
+			name:   "hasTgId yes",
+			params: ClientPageParams{PageSize: 50, HasTgID: "yes"},
+			want:   []string{"juliet@x"},
+		},
+		{
+			name:   "autoRenew on",
+			params: ClientPageParams{PageSize: 50, AutoRenew: "on"},
+			want:   []string{"juliet@x"},
+		},
+		{
+			name:   "usage range is inclusive on both bounds",
+			params: ClientPageParams{PageSize: 50, UsageFrom: pagingGB, UsageTo: 5 * pagingGB},
+			want:   []string{"alpha@x", "bravo@x", "delta@x"},
+		},
+		{
+			name:   "expiry range excludes never and delayed start",
+			params: ClientPageParams{PageSize: 50, ExpiryFrom: now, ExpiryTo: now + 10*pagingDay},
+			want:   []string{"golf@x"},
+		},
+		{
+			name:   "filters combine with AND",
+			params: ClientPageParams{PageSize: 50, Filter: "depleted", Protocol: "trojan"},
+			want:   []string{"charlie@x", "foxtrot@x"},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			resp, err := svc.ListPaged(inboundSvc, settingSvc, tc.params)
+			if err != nil {
+				t.Fatalf("ListPaged: %v", err)
+			}
+			got := pagedEmails(resp.Items)
+			if !slices.Equal(got, tc.want) {
+				t.Fatalf("emails = %v, want %v", got, tc.want)
+			}
+			if resp.Filtered != len(tc.want) {
+				t.Fatalf("filtered = %d, want %d", resp.Filtered, len(tc.want))
+			}
+			if resp.Total != 12 {
+				t.Fatalf("total = %d, want 12", resp.Total)
+			}
+		})
+	}
+}
+
+func TestListPagedSorting(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	seedPagingClients(t)
+
+	tests := []struct {
+		name  string
+		sort  string
+		order string
+		want  []string
+	}{
+		{
+			name: "no sort key keeps insertion order",
+			want: []string{"alpha@x", "bravo@x", "charlie@x"},
+		},
+		{
+			name: "email ascending", sort: "email", order: "ascend",
+			want: []string{"alpha@x", "bravo@x", "charlie@x"},
+		},
+		{
+			name: "email descending", sort: "email", order: "descend",
+			want: []string{"kilo_1@x", "kilo1@x", "juliet@x"},
+		},
+		{
+			name: "traffic descending", sort: "traffic", order: "descend",
+			want: []string{"charlie@x", "hotel@x", "alpha@x"},
+		},
+		{
+			name: "remaining descending puts unlimited quotas first", sort: "remaining", order: "descend",
+			want: []string{"alpha@x", "india@x", "juliet@x"},
+		},
+		{
+			name: "expiry ascending starts with the expired", sort: "expiryTime", order: "ascend",
+			want: []string{"delta@x", "foxtrot@x", "golf@x"},
+		},
+		{
+			name: "createdAt ascending follows insertion", sort: "createdAt", order: "ascend",
+			want: []string{"alpha@x", "bravo@x", "charlie@x"},
+		},
+		{
+			name: "updatedAt descending starts with the newest", sort: "updatedAt", order: "descend",
+			want: []string{"alpha@x", "bravo@x", "charlie@x"},
+		},
+		{
+			name: "lastOnline descending breaks ties on the id, reversed too", sort: "lastOnline", order: "descend",
+			want: []string{"bravo@x", "alpha@x", "kilo1@x"},
+		},
+		{
+			name: "enable ascending puts disabled first", sort: "enable", order: "ascend",
+			want: []string{"echo@x", "foxtrot@x", "alpha@x"},
+		},
+		{
+			name: "inboundIds descending puts the widest attachment first", sort: "inboundIds", order: "descend",
+			want: []string{"bravo@x", "alpha@x", "charlie@x"},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 3, Sort: tc.sort, Order: tc.order})
+			if err != nil {
+				t.Fatalf("ListPaged: %v", err)
+			}
+			got := pagedEmails(resp.Items)
+			if !slices.Equal(got, tc.want) {
+				t.Fatalf("emails = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestListPagedPagination(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	seedPagingClients(t)
+
+	t.Run("second page continues where the first stopped", func(t *testing.T) {
+		resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 2, PageSize: 5, Sort: "email", Order: "ascend"})
+		if err != nil {
+			t.Fatalf("ListPaged: %v", err)
+		}
+		want := []string{"foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x"}
+		if got := pagedEmails(resp.Items); !slices.Equal(got, want) {
+			t.Fatalf("emails = %v, want %v", got, want)
+		}
+		if resp.Page != 2 || resp.PageSize != 5 {
+			t.Fatalf("page/pageSize = %d/%d, want 2/5", resp.Page, resp.PageSize)
+		}
+	})
+
+	t.Run("page past the end is empty but keeps the counts", func(t *testing.T) {
+		resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 9, PageSize: 5})
+		if err != nil {
+			t.Fatalf("ListPaged: %v", err)
+		}
+		if len(resp.Items) != 0 {
+			t.Fatalf("items = %v, want none", pagedEmails(resp.Items))
+		}
+		if resp.Filtered != 12 || resp.Total != 12 {
+			t.Fatalf("filtered/total = %d/%d, want 12/12", resp.Filtered, resp.Total)
+		}
+	})
+
+	t.Run("page size is clamped to the maximum", func(t *testing.T) {
+		resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 5000})
+		if err != nil {
+			t.Fatalf("ListPaged: %v", err)
+		}
+		if resp.PageSize != clientPageMaxSize {
+			t.Fatalf("pageSize = %d, want %d", resp.PageSize, clientPageMaxSize)
+		}
+	})
+}
+
+func TestListPagedRowContents(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	seedPagingClients(t)
+
+	resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50})
+	if err != nil {
+		t.Fatalf("ListPaged: %v", err)
+	}
+	byEmail := make(map[string]ClientSlim, len(resp.Items))
+	for _, it := range resp.Items {
+		byEmail[it.Email] = it
+	}
+
+	t.Run("attachments are reported in inbound order", func(t *testing.T) {
+		got := byEmail["bravo@x"].InboundIds
+		if !slices.Equal(got, []int{1, 2}) {
+			t.Fatalf("inboundIds = %v, want [1 2]", got)
+		}
+	})
+
+	t.Run("an unattached client reports no inbounds", func(t *testing.T) {
+		if got := byEmail["kilo_1@x"].InboundIds; len(got) != 0 {
+			t.Fatalf("inboundIds = %v, want none", got)
+		}
+	})
+
+	t.Run("traffic counters ride along with the row", func(t *testing.T) {
+		got := byEmail["hotel@x"].Traffic
+		if got == nil {
+			t.Fatal("traffic = nil, want the seeded counters")
+		}
+		if want := 10*pagingGB - pagingGB/2; got.Up+got.Down != want {
+			t.Fatalf("used = %d, want %d", got.Up+got.Down, want)
+		}
+	})
+
+	t.Run("groups list every name in use", func(t *testing.T) {
+		if !slices.Equal(resp.Groups, []string{"vip", "VIP"}) && !slices.Equal(resp.Groups, []string{"VIP", "vip"}) {
+			t.Fatalf("groups = %v, want VIP and vip", resp.Groups)
+		}
+	})
+}
+
+func TestListPagedSummary(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	seedPagingClients(t)
+
+	resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 5, Filter: "depleted"})
+	if err != nil {
+		t.Fatalf("ListPaged: %v", err)
+	}
+	s := resp.Summary
+
+	t.Run("counts stay whole-panel while the page is filtered", func(t *testing.T) {
+		if s.Total != 12 {
+			t.Fatalf("total = %d, want 12", s.Total)
+		}
+		if s.DepletedCount != 3 {
+			t.Fatalf("depletedCount = %d, want 3", s.DepletedCount)
+		}
+		if s.ExpiringCount != 2 {
+			t.Fatalf("expiringCount = %d, want 2", s.ExpiringCount)
+		}
+		if s.DeactiveCount != 1 {
+			t.Fatalf("deactiveCount = %d, want 1", s.DeactiveCount)
+		}
+		if s.Active != 6 {
+			t.Fatalf("active = %d, want 6", s.Active)
+		}
+	})
+
+	t.Run("every client lands in exactly one counter", func(t *testing.T) {
+		if sum := s.Active + s.DepletedCount + s.ExpiringCount + s.DeactiveCount; sum != s.Total {
+			t.Fatalf("buckets sum to %d, want %d", sum, s.Total)
+		}
+	})
+
+	t.Run("bucket lists carry the matching emails", func(t *testing.T) {
+		if want := []string{"charlie@x", "delta@x", "foxtrot@x"}; !slices.Equal(s.Depleted, want) {
+			t.Fatalf("depleted = %v, want %v", s.Depleted, want)
+		}
+		if want := []string{"golf@x", "hotel@x"}; !slices.Equal(s.Expiring, want) {
+			t.Fatalf("expiring = %v, want %v", s.Expiring, want)
+		}
+		if want := []string{"echo@x"}; !slices.Equal(s.Deactive, want) {
+			t.Fatalf("deactive = %v, want %v", s.Deactive, want)
+		}
+	})
+}
+
+func TestListPagedSummaryEmailListsAreCapped(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	db := database.GetDB()
+
+	const n = clientSummaryEmailCap + 25
+	past := time.Now().UnixMilli() - pagingDay
+	for i := range n {
+		rec := model.ClientRecord{Email: "bulk-" + strconv.Itoa(i) + "@x", Enable: true, TotalGB: pagingGB, ExpiryTime: past}
+		if err := db.Create(&rec).Error; err != nil {
+			t.Fatalf("create client %d: %v", i, err)
+		}
+	}
+
+	resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 25})
+	if err != nil {
+		t.Fatalf("ListPaged: %v", err)
+	}
+	if resp.Summary.DepletedCount != n {
+		t.Fatalf("depletedCount = %d, want %d", resp.Summary.DepletedCount, n)
+	}
+	if len(resp.Summary.Depleted) != clientSummaryEmailCap {
+		t.Fatalf("depleted list = %d entries, want %d", len(resp.Summary.Depleted), clientSummaryEmailCap)
+	}
+}
+
+func TestListPagedGlobalTrafficOverlay(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	seedPagingClients(t)
+
+	// bravo has used 1GB of its 10GB locally; a master reporting 10GB of
+	// cross-panel usage has to move it into the depleted bucket.
+	if err := inboundSvc.AcceptGlobalTraffic("master-guid", []*xray.ClientTraffic{
+		{Email: "bravo@x", Up: 4 * pagingGB, Down: 6 * pagingGB},
+	}); err != nil {
+		t.Fatalf("AcceptGlobalTraffic: %v", err)
+	}
+
+	resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50, Filter: "depleted"})
+	if err != nil {
+		t.Fatalf("ListPaged: %v", err)
+	}
+	want := []string{"bravo@x", "charlie@x", "delta@x", "foxtrot@x"}
+	if got := pagedEmails(resp.Items); !slices.Equal(got, want) {
+		t.Fatalf("depleted = %v, want %v", got, want)
+	}
+	if resp.Summary.DepletedCount != 4 {
+		t.Fatalf("depletedCount = %d, want 4", resp.Summary.DepletedCount)
+	}
+	for _, it := range resp.Items {
+		if it.Email != "bravo@x" {
+			continue
+		}
+		if it.Traffic == nil || it.Traffic.Up+it.Traffic.Down != 10*pagingGB {
+			t.Fatalf("bravo traffic = %+v, want the overlaid 10GB", it.Traffic)
+		}
+	}
+}
+
+func TestClientQueryOnlineEmails(t *testing.T) {
+	_, _, _ = setupPagingServices(t)
+	seedPagingClients(t)
+
+	q := newClientQuery(database.GetDB(), time.Now().UnixMilli(), 0, 0)
+	emails, count, err := q.onlineEmails([]string{"alpha@x", "echo@x", "ghost@x", "kilo1@x"})
+	if err != nil {
+		t.Fatalf("onlineEmails: %v", err)
+	}
+	if want := []string{"alpha@x", "kilo1@x"}; !slices.Equal(emails, want) {
+		t.Fatalf("online = %v, want %v (disabled and unknown emails drop out)", emails, want)
+	}
+	if count != 2 {
+		t.Fatalf("count = %d, want 2", count)
+	}
+}
+
+func TestEmailInCondChunksLargeSets(t *testing.T) {
+	emails := make([]string, sqlInChunk+1)
+	for i := range emails {
+		emails[i] = "e" + strconv.Itoa(i)
+	}
+
+	cond, args := emailInCond("c.email", emails)
+	if want := "(c.email IN ? OR c.email IN ?)"; cond != want {
+		t.Fatalf("cond = %q, want %q", cond, want)
+	}
+	if len(args) != 2 {
+		t.Fatalf("args = %d chunks, want 2", len(args))
+	}
+	if first, ok := args[0].([]string); !ok || len(first) != sqlInChunk {
+		t.Fatalf("first chunk = %v, want %d entries", args[0], sqlInChunk)
+	}
+
+	emptyCond, emptyArgs := emailInCond("c.email", nil)
+	if emptyCond != "1 = 0" || emptyArgs != nil {
+		t.Fatalf("empty set = %q/%v, want an always-false predicate", emptyCond, emptyArgs)
+	}
+}
+
+func TestEscapeLikeLiteral(t *testing.T) {
+	tests := []struct {
+		in   string
+		want string
+	}{
+		{"plain", "plain"},
+		{"a_b", `a\_b`},
+		{"50%", `50\%`},
+		{`back\slash`, `back\\slash`},
+	}
+	for _, tc := range tests {
+		if got := escapeLikeLiteral(tc.in); got != tc.want {
+			t.Fatalf("escapeLikeLiteral(%q) = %q, want %q", tc.in, got, tc.want)
+		}
+	}
+}
+
+func TestListPagedEmptyPanel(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+
+	resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{})
+	if err != nil {
+		t.Fatalf("ListPaged on a panel with no clients: %v", err)
+	}
+	if len(resp.Items) != 0 || resp.Total != 0 || resp.Filtered != 0 {
+		t.Fatalf("items/total/filtered = %d/%d/%d, want 0/0/0", len(resp.Items), resp.Total, resp.Filtered)
+	}
+	if resp.Summary.Active != 0 || resp.Summary.DepletedCount != 0 {
+		t.Fatalf("summary = %+v, want zeroed counters", resp.Summary)
+	}
+	if resp.Groups == nil {
+		t.Fatal("groups = nil, want an empty list so the filter drawer renders")
+	}
+}