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:
Mr. Nickson
2026-08-14 17:45:31 +03:00
committed by GitHub
parent d05e44e401
commit ecadfd0e60
4 changed files with 114 additions and 233 deletions
+3 -87
View File
@@ -85,51 +85,6 @@ export interface ClientSpeedEntry {
type ClientStatRow = ClientTraffic & { email?: string };
// Mirror of the server's buildClientsSummary (web/service/client.go). The
// client_stats WS event already carries every client's traffic, so the
// summary card can be recomputed live from it instead of waiting for a list
// refetch — keep the two in lockstep.
export function computeClientsSummary(
stats: ClientStatRow[],
onlineSet: Set<string>,
expireDiffMs: number,
trafficDiffBytes: number,
): ClientsSummary {
const now = Date.now();
const online: string[] = [];
const depleted: string[] = [];
const expiring: string[] = [];
const deactive: string[] = [];
let active = 0;
for (const c of stats) {
const email = c.email;
if (!email) continue;
const used = (c.up || 0) + (c.down || 0);
const total = c.total || 0;
const exhausted = total > 0 && used >= total;
const expired = (c.expiryTime || 0) > 0 && (c.expiryTime || 0) <= now;
if (c.enable && onlineSet.has(email)) online.push(email);
if (exhausted || expired) { depleted.push(email); continue; }
if (!c.enable) { deactive.push(email); continue; }
const nearExpiry = (c.expiryTime || 0) > 0 && (c.expiryTime || 0) - now < expireDiffMs;
const nearLimit = total > 0 && total - used < trafficDiffBytes;
if (nearExpiry || nearLimit) expiring.push(email);
else active += 1;
}
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>,
@@ -144,37 +99,6 @@ export function sameSpeedMap(
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(
serverSummary: ClientsSummary,
allClientStats: ClientStatRow[],
onlineSet: Set<string>,
expireDiffMs: number,
trafficDiffBytes: number,
): ClientsSummary {
if (allClientStats.length === 0) return serverSummary;
if (serverSummary.total > allClientStats.length) return serverSummary;
const live = computeClientsSummary(allClientStats, onlineSet, expireDiffMs, trafficDiffBytes);
return { ...live, total: serverSummary.total || live.total };
}
function buildQS(p: ClientQueryParams): string {
const sp = new URLSearchParams();
sp.set('page', String(p.page || 1));
@@ -272,6 +196,7 @@ export function useClients(options: UseClientsOptions = {}) {
// 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).
refetchInterval: 5000,
refetchOnWindowFocus: 'always',
placeholderData: keepPreviousData,
});
@@ -349,17 +274,12 @@ export function useClients(options: UseClientsOptions = {}) {
// 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>>({});
const summary = useMemo<ClientsSummary>(
() => pickClientsSummary(listQuery.data?.summary ?? DEFAULT_SUMMARY, allClientStats, new Set(onlines), expireDiff, trafficDiff),
[allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary],
);
const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
const invalidateAll = useCallback(
() => {
markLocalInvalidate();
setAllClientStats([]);
return Promise.all([
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
@@ -658,12 +578,8 @@ export function useClients(options: UseClientsOptions = {}) {
const applyClientStatsEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
const p = payload as { clients?: ClientStatRow[] };
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
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>();