mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-17 00:31:00 +00:00
fix(clients): stop recomputing the summary badges from the client_stats snapshot (#6169)
* fix(clients): stop recomputing the summary badges from the client_stats snapshot pickClientsSummary's coverage guard (serverSummary.total > allClientStats.length) only catches a net shortfall: an orphaned client_traffics row and a client still missing one can cancel out, or an orphan surplus alone can pass uncaught, and either way the guard fails to fall back (#6116). client_paging.go's q.summary() already derives the same bucket counts with clients as the driving table (LEFT JOIN client_traffics), so it cannot miscount either shape regardless of how the row got there, and listQuery already polls it every 5s — the same cadence client_stats ticks on. The client-side recompute bought no fresher a number than the server already provides on its own poll, only a window to get one wrong, so this drops it: the summary badges now always read serverSummary directly. allClientStats, computeClientsSummary, pickClientsSummary and sameSummaryInputs are removed as dead code along with it; the per-row live traffic patch in applyClientStatsEvent is untouched, since it reads the same snapshot by email match rather than by count and was never exposed to this class of bug. * fix(clients): force a refetch on window focus and drop a stale comment Review feedback on PR #6169: listQuery combines staleTime: Infinity with refetchInterval: 5000, which pauses while the tab is hidden. The WS-driven per-row traffic patch in applyClientStatsEvent has no such visibility gating, so on a background tab a row's live numbers keep moving while the summary badges above them freeze at whatever they were before the tab was hidden, and staleTime: Infinity blocks refetchOnWindowFocus from closing that gap on return. Before this PR the client-side recompute this branch removed happened to paper over the same underlying gap; now that it's gone, the gap is directly visible. refetchOnWindowFocus: 'always' forces exactly one refetch on refocus, ignoring staleTime, without touching the interval/staleTime pairing that governs the rest of this query's behavior. Separately, useInbounds.ts still referenced computeClientsSummary by name in a comment explaining bucket priority; that function no longer exists after this PR. Dropped the comment rather than repoint it, per the repo's no-//-comment convention.
This commit is contained in:
@@ -1,142 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
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 { sameSpeedMap, useClients } from '@/hooks/useClients';
|
||||
import { makeTestQueryClient } from '@/test/test-utils';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import type { ClientsSummary } from '@/schemas/client';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('client summary always reflects the server, never a client_stats recompute (#6116)', () => {
|
||||
const serverSummary: ClientsSummary = {
|
||||
total: 3, active: 3,
|
||||
onlineCount: 0, depletedCount: 0, expiringCount: 0, deactiveCount: 0,
|
||||
online: [], depleted: [], expiring: [], deactive: [],
|
||||
};
|
||||
|
||||
const pagedResponse = {
|
||||
items: [],
|
||||
total: 3,
|
||||
filtered: 3,
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
groups: [],
|
||||
summary: serverSummary,
|
||||
};
|
||||
|
||||
function mockPanel() {
|
||||
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
|
||||
if (url.includes('/clients/list/paged')) return new Msg(true, '', pagedResponse);
|
||||
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, '', { pageSize: 25 });
|
||||
if (url.includes('/clients/onlines')) return new Msg(true, '', []);
|
||||
return new Msg(true, '', null);
|
||||
});
|
||||
}
|
||||
|
||||
function wrapperFor() {
|
||||
const queryClient = makeTestQueryClient();
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadedHook() {
|
||||
mockPanel();
|
||||
const { result } = renderHook(() => useClients(), { 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.fetched).toBe(true));
|
||||
expect(result.current.summary).toEqual(serverSummary);
|
||||
return result;
|
||||
}
|
||||
|
||||
it('stays pinned to the server summary across a client_stats push carrying an orphan row with no matching gap', async () => {
|
||||
const result = await loadedHook();
|
||||
|
||||
act(() => {
|
||||
result.current.applyClientStatsEvent({
|
||||
snapshot: true,
|
||||
clients: [
|
||||
{ email: 'a@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
|
||||
{ email: 'b@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
|
||||
{ email: 'c@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
|
||||
{ email: 'ghost@x', enable: false, up: 0, down: 0, total: 1, expiryTime: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.summary).toEqual(serverSummary);
|
||||
});
|
||||
|
||||
it('stays pinned to the server summary across a client_stats push where an orphan and a gap net out to the server total', async () => {
|
||||
const result = await loadedHook();
|
||||
|
||||
act(() => {
|
||||
result.current.applyClientStatsEvent({
|
||||
snapshot: true,
|
||||
clients: [
|
||||
{ email: 'a@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
|
||||
{ email: 'b@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
|
||||
{ email: 'ghost@x', enable: false, up: 0, down: 0, total: 1, expiryTime: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.summary).toEqual(serverSummary);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user