Browse Source

fix(clients): keep the summary card live without a page refresh

The clients page summary counters (Online / Depleted / Depleting / Disabled
/ Active) came only from the paged-list response (staleTime: Infinity), so
they stayed frozen until a manual refresh or a mutation-triggered refetch —
the per-row columns updated over WebSocket but the summary card did not.

The client_stats WS event already broadcasts every client's traffic
(enable/up/down/total/expiryTime) every few seconds, so recompute the summary
client-side from it: computeClientsSummary mirrors the server's
buildClientsSummary, the latest event is stored in allClientStats, and the
summary is a useMemo over that plus the live onlines set. Falls back to the
server summary until the first event lands and keeps the server's
authoritative total. No extra polling, consistent with the existing
no-REST-fallback traffic design.
MHSanaei 11 hours ago
parent
commit
b67c4c2f81
2 changed files with 113 additions and 3 deletions
  1. 51 3
      frontend/src/hooks/useClients.ts
  2. 62 0
      frontend/src/test/clients-summary.test.ts

+ 51 - 3
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<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, 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<ClientStatRow[]>([]);
+  const summary = useMemo<ClientsSummary>(() => {
+    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<string, ClientTraffic>();
     for (const row of p.clients) {
       if (row && row.email) byEmail.set(row.email, row);

+ 62 - 0
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>): 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([]);
+  });
+});