Files
3x-ui/frontend/src/test/clients-summary.test.ts
T
Sanaei f52c3c4837 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.
2026-07-30 02:49:32 +02:00

143 lines
6.3 KiB
TypeScript

import { describe, it, expect } from 'vitest';
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
// land in the same bucket whether the count comes from the server (list fetch)
// or is recomputed live from the client_stats WS event. A mismatch would make
// the summary card "jump" on refresh.
type Row = ClientTraffic & { email?: string };
const GB = 1024 * 1024 * 1024;
const DAY = 86_400_000;
function row(over: Partial<Row>): Row {
return { email: 'x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0, ...over } as Row;
}
describe('computeClientsSummary', () => {
it('buckets each client the way the Go service does', () => {
const now = Date.now();
const stats: Row[] = [
row({ email: 'online@x', enable: true }),
row({ email: 'offline@x', enable: true }),
row({ email: 'disabled@x', enable: false }),
row({ email: 'exhausted@x', enable: true, total: 1 * GB, up: 1 * GB }),
row({ email: 'expired@x', enable: true, expiryTime: now - DAY }),
row({ email: 'nearexpiry@x', enable: true, expiryTime: now + DAY }),
row({ email: 'nearlimit@x', enable: true, total: 10 * GB, up: 9.9 * GB }),
];
const online = new Set(['online@x', 'disabled@x']); // disabled-but-online must NOT count as online
const expireDiffMs = 3 * DAY;
const trafficDiffBytes = 1 * GB;
const s = computeClientsSummary(stats, online, expireDiffMs, trafficDiffBytes);
expect(s.total).toBe(7);
expect(s.online).toEqual(['online@x']);
expect(s.depleted.sort()).toEqual(['exhausted@x', 'expired@x']);
expect(s.deactive).toEqual(['disabled@x']);
expect(s.expiring.sort()).toEqual(['nearexpiry@x', 'nearlimit@x']);
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 }),
];
const s = computeClientsSummary(stats, new Set(['a@x']), 0, 0);
expect(s.depleted).toEqual(['a@x']);
expect(s.deactive).toEqual([]);
expect(s.online).toEqual([]); // disabled is never online
});
it('unlimited + no expiry is active', () => {
const stats: Row[] = [row({ email: 'a@x', enable: true, total: 0, expiryTime: 0 })];
const s = computeClientsSummary(stats, new Set(), 3 * DAY, 1 * GB);
expect(s.active).toBe(1);
expect(s.expiring).toEqual([]);
expect(s.depleted).toEqual([]);
});
});
describe('pickClientsSummary', () => {
const serverSummary: ClientsSummary = {
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)', () => {
const shortSnapshot: Row[] = Array.from({ length: 58 }, (_, i) => row({ email: `c${i}@x`, enable: true }));
const s = pickClientsSummary(serverSummary, shortSnapshot, new Set(), 3 * DAY, 1 * GB);
expect(s).toEqual(serverSummary);
});
it('uses the live recompute when the snapshot covers every client', () => {
const fullSnapshot: Row[] = Array.from({ length: 67 }, (_, i) => row({ email: `c${i}@x`, enable: true }));
const s = pickClientsSummary(serverSummary, fullSnapshot, new Set(), 3 * DAY, 1 * GB);
expect(s.total).toBe(67);
expect(s.active).toBe(67);
});
it('falls back to the server summary before the first WS snapshot arrives', () => {
const s = pickClientsSummary(serverSummary, [], new Set(), 3 * DAY, 1 * GB);
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);
});
});