mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-19 00:27:14 +00:00
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.
This commit is contained in:
@@ -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;
|
const merged = {
|
||||||
return {
|
|
||||||
...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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user