diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index 8374ac30..51b418c6 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -68,6 +68,42 @@ const DEFAULT_SUMMARY: ClientsSummary = { total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [], }; +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, + 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, online, depleted, expiring, deactive }; +} + function buildQS(p: ClientQueryParams): string { const sp = new URLSearchParams(); sp.set('page', String(p.page || 1)); @@ -176,13 +212,12 @@ export function useClients() { const clients = listQuery.data?.items ?? []; const total = listQuery.data?.total ?? 0; const filtered = listQuery.data?.filtered ?? 0; - const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY; const allGroups = listQuery.data?.groups ?? []; const fetched = listQuery.data !== undefined; const loading = listQuery.isFetching; const inbounds = inboundOptionsQuery.data ?? []; - const onlines = onlinesQuery.data ?? []; + const onlines = useMemo(() => onlinesQuery.data ?? [], [onlinesQuery.data]); const defaults = defaultsQuery.data ?? {}; const subSettings: SubSettings = useMemo(() => ({ @@ -207,6 +242,18 @@ export function useClients() { const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824; const pageSize = (defaults.pageSize as number) ?? 0; + // Live summary: the client_stats WS event refreshes allClientStats every few + // seconds, so the top counters track reality without a page refresh. Falls + // back to the server-computed summary until the first event lands, and keeps + // the server's authoritative total for the headline count. + const [allClientStats, setAllClientStats] = useState([]); + const summary = useMemo(() => { + const serverSummary = listQuery.data?.summary ?? DEFAULT_SUMMARY; + if (allClientStats.length === 0) return serverSummary; + const live = computeClientsSummary(allClientStats, new Set(onlines), expireDiff, trafficDiff); + return { ...live, total: serverSummary.total || live.total }; + }, [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary]); + // Client mutations (add/update/remove/attach/detach/resetTraffic/…) all // mutate inbound rows server-side too — adding a client appends to // settings.clients on each attached inbound, the slim list's per-inbound @@ -438,8 +485,9 @@ export function useClients() { const applyClientStatsEvent = useCallback((payload: unknown) => { if (!payload || typeof payload !== 'object') return; - const p = payload as { clients?: (ClientTraffic & { email?: string })[] }; + const p = payload as { clients?: ClientStatRow[] }; if (!Array.isArray(p.clients) || p.clients.length === 0) return; + setAllClientStats(p.clients); const byEmail = new Map(); for (const row of p.clients) { if (row && row.email) byEmail.set(row.email, row); diff --git a/frontend/src/test/clients-summary.test.ts b/frontend/src/test/clients-summary.test.ts new file mode 100644 index 00000000..bbe6e9e0 --- /dev/null +++ b/frontend/src/test/clients-summary.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; + +import { computeClientsSummary } from '@/hooks/useClients'; +import type { ClientTraffic } 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 { + 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('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([]); + }); +});