Browse Source

perf(inbounds): keep unchanged rows and online sets across websocket pushes

Every client_stats push carries the totals of all inbounds, and
applyClientStatsEvent rebuilt each row it listed, so every push replaced
all rows, re-ran the client rollup (a JSON parse of every inbound's
settings) and re-rendered the whole table even when no number moved. Every
traffic push also built new online and active maps, re-running the same
rollup.

Rows are now rebuilt only when their totals or a client's numbers change,
and the previous maps are kept when a push repeats the same sets. Measured
in jsdom with 450 inbounds of 50 clients each: an unchanged client_stats
push went from 7.9ms to 0.6ms with no row rebuilt, and a repeated traffic
push from 13.5ms to 8.3ms without the rollup.
Sanaei 14 hours ago
parent
commit
7fc86f87de

+ 36 - 5
frontend/src/pages/inbounds/useInbounds.ts

@@ -119,6 +119,18 @@ function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string
   return map;
   return map;
 }
 }
 
 
+// Most pushes repeat the previous online sets; handing back a new Map anyway
+// re-ran the client rollup over every inbound on each traffic event.
+function sameGuidSets(a: Map<string, Set<string>>, b: Map<string, Set<string>>): boolean {
+  if (a.size !== b.size) return false;
+  for (const [key, set] of b) {
+    const prev = a.get(key);
+    if (!prev || prev.size !== set.size) return false;
+    for (const value of set) if (!prev.has(value)) return false;
+  }
+  return true;
+}
+
 async function fetchLastOnlineMap(): Promise<Record<string, number>> {
 async function fetchLastOnlineMap(): Promise<Record<string, number>> {
   const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
   const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
@@ -440,10 +452,12 @@ export function useInbounds() {
       setOnlineClients(p.onlineClients);
       setOnlineClients(p.onlineClients);
     }
     }
     if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
     if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
-      setOnlineByGuid(toGuidOnlineMap(p.onlineByGuid));
+      const next = toGuidOnlineMap(p.onlineByGuid);
+      setOnlineByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
     }
     }
     if (p.activeInbounds && typeof p.activeInbounds === 'object') {
     if (p.activeInbounds && typeof p.activeInbounds === 'object') {
-      setActiveByGuid(toGuidOnlineMap(p.activeInbounds));
+      const next = toGuidOnlineMap(p.activeInbounds);
+      setActiveByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
     }
     }
     if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
     if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
       setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
       setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
@@ -537,8 +551,7 @@ export function useInbounds() {
           ? stats.map((stat) => {
           ? stats.map((stat) => {
               const su = byEmail.get(stat.email);
               const su = byEmail.get(stat.email);
               if (!su) return stat;
               if (!su) return stat;
-              statsTouched = true;
-              return {
+              const merged = {
                 ...stat,
                 ...stat,
                 up: typeof su.up === 'number' ? su.up : stat.up,
                 up: typeof su.up === 'number' ? su.up : stat.up,
                 down: typeof su.down === 'number' ? su.down : stat.down,
                 down: typeof su.down === 'number' ? su.down : stat.down,
@@ -546,9 +559,27 @@ export function useInbounds() {
                 expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime,
                 expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime,
                 enable: typeof su.enable === 'boolean' ? su.enable : stat.enable,
                 enable: typeof su.enable === 'boolean' ? su.enable : stat.enable,
               } as ClientStats;
               } as ClientStats;
+              if (
+                merged.up === stat.up &&
+                merged.down === stat.down &&
+                merged.total === stat.total &&
+                merged.expiryTime === stat.expiryTime &&
+                merged.enable === stat.enable
+              ) {
+                return stat;
+              }
+              statsTouched = true;
+              return merged;
             })
             })
           : null;
           : null;
-      if (!upd && !statsTouched) return ib;
+      // Every push lists all inbounds' totals, so only a row whose numbers moved counts.
+      const inboundMoved =
+        !!upd &&
+        ((typeof upd.up === 'number' && upd.up !== ib.up) ||
+          (typeof upd.down === 'number' && upd.down !== ib.down) ||
+          (typeof upd.total === 'number' && upd.total !== ib.total) ||
+          (typeof upd.enable === 'boolean' && upd.enable !== ib.enable));
+      if (!inboundMoved && !statsTouched) return ib;
       touched = true;
       touched = true;
       const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance;
       const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance;
       if (upd) {
       if (upd) {

+ 107 - 0
frontend/src/test/inbounds-ws-identity.test.tsx

@@ -0,0 +1,107 @@
+import type { ReactNode } from 'react';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { describe, expect, it } from 'vitest';
+
+import { keys } from '@/api/queryKeys';
+import { useInbounds } from '@/pages/inbounds/useInbounds';
+
+import { makeTestQueryClient } from './test-utils';
+
+function seedInbounds() {
+  const rows = [1, 2].map((id) => ({
+    id,
+    protocol: 'vless',
+    tag: `in-${id}`,
+    enable: true,
+    up: 10,
+    down: 20,
+    total: 0,
+    expiryTime: 0,
+    settings: JSON.stringify({ clients: [{ email: `c${id}@x`, enable: true }] }),
+    clientStats: [
+      { email: `c${id}@x`, up: 1, down: 2, total: 0, expiryTime: 0, enable: true, inboundId: id },
+    ],
+  }));
+  const queryClient = makeTestQueryClient();
+  queryClient.setQueryData(keys.inbounds.slim(), rows);
+  queryClient.setQueryData(keys.clients.onlines(), []);
+  queryClient.setQueryData(keys.clients.onlinesByGuid(), {});
+  queryClient.setQueryData(keys.clients.activeInbounds(), {});
+  queryClient.setQueryData(keys.clients.lastOnline(), {});
+  queryClient.setQueryData(keys.settings.defaults(), {});
+  const wrapper = ({ children }: { children: ReactNode }) => (
+    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+  );
+  return { rows, wrapper };
+}
+
+async function renderInbounds() {
+  const { rows, wrapper } = seedInbounds();
+  const hook = renderHook(() => useInbounds(), { wrapper });
+  await waitFor(() => expect(hook.result.current.dbInbounds).toHaveLength(2));
+  return { rows, result: hook.result };
+}
+
+// Every client_stats push carries all inbounds' totals, so rebuilding a row whether or
+// not its numbers moved re-ran the client rollup and the whole table on each push.
+describe('inbound websocket merges keep unchanged state', () => {
+  it('keeps rows and the client rollup when a client_stats push changes nothing', async () => {
+    const { rows, result } = await renderInbounds();
+    const before = result.current.dbInbounds;
+    const rollup = result.current.clientCount;
+
+    act(() =>
+      result.current.applyClientStatsEvent({
+        inbounds: rows.map((r) => ({
+          id: r.id,
+          up: r.up,
+          down: r.down,
+          total: r.total,
+          enable: r.enable,
+        })),
+        clients: [{ email: 'c1@x', up: 1, down: 2, total: 0, expiryTime: 0, enable: true }],
+      }),
+    );
+
+    expect(result.current.dbInbounds).toBe(before);
+    expect(result.current.clientCount).toBe(rollup);
+  });
+
+  it('still rebuilds exactly the rows whose numbers moved', async () => {
+    const { result } = await renderInbounds();
+    const before = result.current.dbInbounds;
+
+    act(() =>
+      result.current.applyClientStatsEvent({
+        inbounds: [
+          { id: 1, up: 99, down: 20, total: 0, enable: true },
+          { id: 2, up: 10, down: 20, total: 0, enable: true },
+        ],
+        clients: [{ email: 'c2@x', up: 5, down: 2, total: 0, expiryTime: 0, enable: true }],
+      }),
+    );
+
+    const [first, second] = result.current.dbInbounds;
+    expect(first).not.toBe(before[0]);
+    expect(first.up).toBe(99);
+    expect(second).not.toBe(before[1]);
+    expect(second.clientStats?.[0]?.up).toBe(5);
+  });
+
+  it('keeps the client rollup when a traffic push repeats the same online sets', async () => {
+    const { result } = await renderInbounds();
+    const push = () =>
+      result.current.applyTrafficEvent({
+        onlineClients: ['c1@x'],
+        onlineByGuid: { 'node:1': ['c1@x'] },
+        activeInbounds: { 'node:1': ['in-1'] },
+      });
+    act(push);
+    const rollup = result.current.clientCount;
+
+    act(push);
+
+    expect(result.current.clientCount).toBe(rollup);
+  });
+});