diff --git a/frontend/src/pages/inbounds/useInbounds.ts b/frontend/src/pages/inbounds/useInbounds.ts index f6510cd66..b1e2b3725 100644 --- a/frontend/src/pages/inbounds/useInbounds.ts +++ b/frontend/src/pages/inbounds/useInbounds.ts @@ -119,6 +119,18 @@ function toGuidOnlineMap(data: Record): Map>, b: Map>): 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> { const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true }); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline'); @@ -440,10 +452,12 @@ export function useInbounds() { setOnlineClients(p.onlineClients); } 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') { - setActiveByGuid(toGuidOnlineMap(p.activeInbounds)); + const next = toGuidOnlineMap(p.activeInbounds); + setActiveByGuid((prev) => (sameGuidSets(prev, next) ? prev : next)); } if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') { setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! })); @@ -537,8 +551,7 @@ export function useInbounds() { ? stats.map((stat) => { const su = byEmail.get(stat.email); if (!su) return stat; - statsTouched = true; - return { + const merged = { ...stat, up: typeof su.up === 'number' ? su.up : stat.up, 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, enable: typeof su.enable === 'boolean' ? su.enable : stat.enable, } 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; - 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; const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance; if (upd) { diff --git a/frontend/src/test/inbounds-ws-identity.test.tsx b/frontend/src/test/inbounds-ws-identity.test.tsx new file mode 100644 index 000000000..8760dc78c --- /dev/null +++ b/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 }) => ( + {children} + ); + 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); + }); +});