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.
This commit is contained in:
Sanaei
2026-07-30 02:49:32 +02:00
parent 1e2d6f6081
commit f52c3c4837
18 changed files with 1927 additions and 528 deletions
@@ -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');
});
});