feat(frontend): migrate useClients to TanStack Query

Replaces 12 hand-rolled mutation callbacks and a tangle of useState +
useRef + useEffect with one useQuery (paged list) + nine useMutation
wrappers. The list query uses keepPreviousData so paging/filter
changes don't blank the table mid-fetch.

The setQuery shallow-compare logic is preserved for backward
compatibility with ClientsPage's effect that rebuilds the params on
every render. Internally setQuery only updates state when the params
actually differ — Query's queryKey equality handles the rest.

WS-driven applyTrafficEvent / applyClientStatsEvent now mutate the
query cache via setQueryData(['clients', 'list', currentParams]) so
per-second stats updates skip a full refetch. applyInvalidate is gone
from the hook — the bridge owns coarse 'clients' invalidation.

ClientsPage drops the invalidate handler from its useWebSocket
subscription; auxiliary queries (inboundOptions, defaults, onlines)
load via TanStack Query and are shared with useInbounds via the same
query keys.
This commit is contained in:
MHSanaei 2026-05-24 19:03:47 +02:00
parent 864315448e
commit 967b9aba4b
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
3 changed files with 229 additions and 199 deletions

View file

@ -14,9 +14,11 @@ export const keys = {
inbounds: { inbounds: {
root: () => ['inbounds'] as const, root: () => ['inbounds'] as const,
slim: () => ['inbounds', 'slim'] as const, slim: () => ['inbounds', 'slim'] as const,
options: () => ['inbounds', 'options'] as const,
}, },
clients: { clients: {
root: () => ['clients'] as const, root: () => ['clients'] as const,
list: (params: unknown) => ['clients', 'list', params] as const,
onlines: () => ['clients', 'onlines'] as const, onlines: () => ['clients', 'onlines'] as const,
lastOnline: () => ['clients', 'lastOnline'] as const, lastOnline: () => ['clients', 'lastOnline'] as const,
}, },

View file

@ -1,5 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
import { keys } from '@/api/queryKeys';
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const; const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
@ -84,22 +87,49 @@ interface ClientPageResponse {
} }
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 }; const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
const DEFAULT_SUMMARY: ClientsSummary = {
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
};
function buildQS(p: ClientQueryParams): string {
const sp = new URLSearchParams();
sp.set('page', String(p.page || 1));
sp.set('pageSize', String(p.pageSize || DEFAULT_QUERY.pageSize));
if (p.search) sp.set('search', p.search);
if (p.filter) sp.set('filter', p.filter);
if (p.protocol) sp.set('protocol', p.protocol);
if (p.inbound && p.inbound > 0) sp.set('inbound', String(p.inbound));
if (p.sort) sp.set('sort', p.sort);
if (p.order) sp.set('order', p.order);
return sp.toString();
}
async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageResponse> {
const qs = buildQS(params);
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true }) as ApiMsg<ClientPageResponse>;
if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
return msg.obj;
}
async function fetchInboundOptions(): Promise<InboundOption[]> {
const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true }) as ApiMsg<InboundOption[]>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbound options');
return Array.isArray(msg.obj) ? msg.obj : [];
}
async function fetchDefaults(): Promise<Record<string, unknown>> {
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg<Record<string, unknown>>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
return msg.obj || {};
}
export function useClients() { export function useClients() {
const [clients, setClients] = useState<ClientRecord[]>([]); const queryClient = useQueryClient();
const [total, setTotal] = useState(0);
const [filtered, setFiltered] = useState(0);
const [summary, setSummary] = useState<ClientsSummary>({
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
});
const [inbounds, setInbounds] = useState<InboundOption[]>([]);
const [onlines, setOnlines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false);
const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY); const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
// Shallow-compare against the previous query so callers can pass a fresh // setQuery shallow-compares so callers can pass a fresh object every render
// object on every render (the common React pattern) without triggering a // (the common React pattern) without triggering a re-fetch when nothing
// re-fetch when nothing actually changed. // actually changed.
const setQuery = useCallback((next: ClientQueryParams) => { const setQuery = useCallback((next: ClientQueryParams) => {
setQueryState((prev) => { setQueryState((prev) => {
if ( if (
@ -115,86 +145,69 @@ export function useClients() {
return next; return next;
}); });
}, []); }, []);
const [subSettings, setSubSettings] = useState<SubSettings>({
enable: false, subURI: '', subJsonURI: '', subJsonEnable: false, const listQuery = useQuery({
queryKey: keys.clients.list(query),
queryFn: () => fetchClientPage(query),
staleTime: Infinity,
placeholderData: keepPreviousData,
}); });
const [ipLimitEnable, setIpLimitEnable] = useState(false);
const [tgBotEnable, setTgBotEnable] = useState(false);
const [expireDiff, setExpireDiff] = useState(0);
const [trafficDiff, setTrafficDiff] = useState(0);
const [pageSize, setPageSize] = useState(0);
const clientsRef = useRef<ClientRecord[]>([]); const inboundOptionsQuery = useQuery({
const queryRef = useRef<ClientQueryParams>(query); queryKey: keys.inbounds.options(),
const invalidateTimerRef = useRef<number | null>(null); queryFn: fetchInboundOptions,
staleTime: Infinity,
});
useEffect(() => { clientsRef.current = clients; }, [clients]); const defaultsQuery = useQuery({
useEffect(() => { queryRef.current = query; }, [query]); queryKey: keys.settings.defaults(),
queryFn: fetchDefaults,
staleTime: Infinity,
});
const buildQS = (p: ClientQueryParams) => { const onlinesQuery = useQuery({
const sp = new URLSearchParams(); queryKey: keys.clients.onlines(),
sp.set('page', String(p.page || 1)); queryFn: async () => {
sp.set('pageSize', String(p.pageSize || DEFAULT_QUERY.pageSize)); const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg<string[]>;
if (p.search) sp.set('search', p.search); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
if (p.filter) sp.set('filter', p.filter); return Array.isArray(msg.obj) ? msg.obj : [];
if (p.protocol) sp.set('protocol', p.protocol); },
if (p.inbound && p.inbound > 0) sp.set('inbound', String(p.inbound)); staleTime: Infinity,
if (p.sort) sp.set('sort', p.sort); });
if (p.order) sp.set('order', p.order);
return sp.toString();
};
const refresh = useCallback(async (override?: ClientQueryParams) => { const clients = listQuery.data?.items ?? [];
setLoading(true); const total = listQuery.data?.total ?? 0;
try { const filtered = listQuery.data?.filtered ?? 0;
const params = override ?? queryRef.current; const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
const qs = buildQS(params); const fetched = listQuery.data !== undefined;
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`) as ApiMsg<ClientPageResponse>; const loading = listQuery.isFetching;
if (msg?.success && msg.obj) {
setClients(Array.isArray(msg.obj.items) ? msg.obj.items : []);
setTotal(msg.obj.total ?? 0);
setFiltered(msg.obj.filtered ?? 0);
if (msg.obj.summary) setSummary(msg.obj.summary);
}
setFetched(true);
} finally {
setLoading(false);
}
}, []);
// Inbound options are picker-shaped and don't depend on the clients query — const inbounds = inboundOptionsQuery.data ?? [];
// fetch them once on mount instead of every refresh. const onlines = onlinesQuery.data ?? [];
useEffect(() => {
let cancelled = false;
(async () => {
const msg = await HttpUtil.get('/panel/api/inbounds/options') as ApiMsg<InboundOption[]>;
if (cancelled) return;
if (msg?.success) setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
})();
return () => { cancelled = true; };
}, []);
const fetchSubSettings = useCallback(async () => { const defaults = defaultsQuery.data ?? {};
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg<Record<string, unknown>>; const subSettings: SubSettings = useMemo(() => ({
if (!msg?.success) return; enable: !!defaults.subEnable,
const s = msg.obj || {}; subURI: (defaults.subURI as string) || '',
setSubSettings({ subJsonURI: (defaults.subJsonURI as string) || '',
enable: !!s.subEnable, subJsonEnable: !!defaults.subJsonEnable,
subURI: (s.subURI as string) || '', }), [defaults.subEnable, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable]);
subJsonURI: (s.subJsonURI as string) || '',
subJsonEnable: !!s.subJsonEnable, const ipLimitEnable = !!defaults.ipLimitEnable;
}); const tgBotEnable = !!defaults.tgBotEnable;
setIpLimitEnable(!!s.ipLimitEnable); const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
setTgBotEnable(!!s.tgBotEnable); const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
setExpireDiff(((s.expireDiff as number) ?? 0) * 86400000); const pageSize = (defaults.pageSize as number) ?? 0;
setTrafficDiff(((s.trafficDiff as number) ?? 0) * 1073741824);
setPageSize((s.pageSize as number) ?? 0); const invalidateAll = useCallback(
}, []); () => queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
[queryClient],
);
const refresh = useCallback(async () => {
await invalidateAll();
}, [invalidateAll]);
// hydrate fetches the full client record (uuid, password, flow, ...) for a
// single email. The paged list endpoint omits these to keep the row payload
// tiny; edit / info / qr / link modals call this to get a complete record
// before opening.
const hydrate = useCallback(async (email: string): Promise<{ client: ClientRecord; inboundIds: number[] } | null> => { const hydrate = useCallback(async (email: string): Promise<{ client: ClientRecord; inboundIds: number[] } | null> => {
if (!email) return null; if (!email) return null;
const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`) as ApiMsg<{ client: ClientRecord; inboundIds: number[] }>; const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`) as ApiMsg<{ client: ClientRecord; inboundIds: number[] }>;
@ -202,88 +215,109 @@ export function useClients() {
return msg.obj; return msg.obj;
}, []); }, []);
const create = useCallback(async (payload: unknown) => { const createMut = useMutation({
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as ApiMsg; mutationFn: (payload: unknown) =>
if (msg?.success) await refresh(); HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as Promise<ApiMsg>,
return msg; onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
}, [refresh]); });
const update = useCallback(async (email: string, client: unknown) => { const updateMut = useMutation({
if (!email) return null; mutationFn: ({ email, client }: { email: string; client: unknown }) =>
const encoded = encodeURIComponent(email); HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS) as Promise<ApiMsg>,
const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS) as ApiMsg; onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
if (msg?.success) await refresh(); });
return msg;
}, [refresh]);
const remove = useCallback(async (email: string, keepTraffic = false) => { const removeMut = useMutation({
if (!email) return null; mutationFn: ({ email, keepTraffic }: { email: string; keepTraffic?: boolean }) => {
const encoded = encodeURIComponent(email); const url = keepTraffic
const url = keepTraffic ? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
? `/panel/api/clients/del/${encoded}?keepTraffic=1` : `/panel/api/clients/del/${encodeURIComponent(email)}`;
: `/panel/api/clients/del/${encoded}`; return HttpUtil.post(url) as Promise<ApiMsg>;
const msg = await HttpUtil.post(url) as ApiMsg; },
if (msg?.success) await refresh(); onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
return msg; });
}, [refresh]);
const removeMany = useCallback(async (emails: string[], keepTraffic = false) => { const removeManyMut = useMutation({
if (!Array.isArray(emails) || emails.length === 0) return []; mutationFn: async ({ emails, keepTraffic }: { emails: string[]; keepTraffic?: boolean }) => {
const suffix = keepTraffic ? '?keepTraffic=1' : ''; const suffix = keepTraffic ? '?keepTraffic=1' : '';
const results = await Promise.all(emails.map((email) => { const results = await Promise.all(emails.map((email) => {
const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`; const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
return HttpUtil.post(url, undefined, { silent: true }) as Promise<ApiMsg>; return HttpUtil.post(url, undefined, { silent: true }) as Promise<ApiMsg>;
})); }));
await refresh(); return results;
return results; },
}, [refresh]); onSuccess: () => invalidateAll(),
});
const bulkAdjust = useCallback(async (emails: string[], addDays: number, addBytes: number) => { const bulkAdjustMut = useMutation({
if (!Array.isArray(emails) || emails.length === 0) return null; mutationFn: (payload: { emails: string[]; addDays: number; addBytes: number }) =>
const msg = await HttpUtil.post( HttpUtil.post(
'/panel/api/clients/bulkAdjust', '/panel/api/clients/bulkAdjust',
{ emails, addDays, addBytes }, payload,
JSON_HEADERS, JSON_HEADERS,
) as ApiMsg<{ adjusted: number; skipped?: { email: string; reason: string }[] }>; ) as Promise<ApiMsg<{ adjusted: number; skipped?: { email: string; reason: string }[] }>>,
if (msg?.success) await refresh(); onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
return msg; });
}, [refresh]);
const attach = useCallback(async (email: string, inboundIds: number[]) => { const attachMut = useMutation({
if (!email) return null; mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
const encoded = encodeURIComponent(email); HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, JSON_HEADERS) as Promise<ApiMsg>,
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS) as ApiMsg; onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
if (msg?.success) await refresh(); });
return msg;
}, [refresh]);
const detach = useCallback(async (email: string, inboundIds: number[]) => { const detachMut = useMutation({
if (!email) return null; mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
const encoded = encodeURIComponent(email); HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS) as Promise<ApiMsg>,
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS) as ApiMsg; onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
if (msg?.success) await refresh(); });
return msg;
}, [refresh]);
const resetTraffic = useCallback(async (client: ClientRecord) => { const resetTrafficMut = useMutation({
if (!client?.email) return null; mutationFn: (email: string) =>
const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`; HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`) as Promise<ApiMsg>,
const msg = await HttpUtil.post(url) as ApiMsg; onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
if (msg?.success) await refresh(); });
return msg;
}, [refresh]);
const resetAllTraffics = useCallback(async () => { const resetAllTrafficsMut = useMutation({
const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics') as ApiMsg; mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics') as Promise<ApiMsg>,
if (msg?.success) await refresh(); onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
return msg; });
}, [refresh]);
const delDepleted = useCallback(async () => { const delDepletedMut = useMutation({
const msg = await HttpUtil.post('/panel/api/clients/delDepleted') as ApiMsg<{ deleted?: number }>; mutationFn: () => HttpUtil.post('/panel/api/clients/delDepleted') as Promise<ApiMsg<{ deleted?: number }>>,
if (msg?.success) await refresh(); onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
return msg; });
}, [refresh]);
const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
const update = useCallback((email: string, client: unknown) => {
if (!email) return Promise.resolve(null as unknown as ApiMsg);
return updateMut.mutateAsync({ email, client });
}, [updateMut]);
const remove = useCallback((email: string, keepTraffic = false) => {
if (!email) return Promise.resolve(null as unknown as ApiMsg);
return removeMut.mutateAsync({ email, keepTraffic });
}, [removeMut]);
const removeMany = useCallback((emails: string[], keepTraffic = false) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve([] as ApiMsg[]);
return removeManyMut.mutateAsync({ emails, keepTraffic });
}, [removeManyMut]);
const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
}, [bulkAdjustMut]);
const attach = useCallback((email: string, inboundIds: number[]) => {
if (!email) return Promise.resolve(null as unknown as ApiMsg);
return attachMut.mutateAsync({ email, inboundIds });
}, [attachMut]);
const detach = useCallback((email: string, inboundIds: number[]) => {
if (!email) return Promise.resolve(null as unknown as ApiMsg);
return detachMut.mutateAsync({ email, inboundIds });
}, [detachMut]);
const resetTraffic = useCallback((client: ClientRecord) => {
if (!client?.email) return Promise.resolve(null as unknown as ApiMsg);
return resetTrafficMut.mutateAsync(client.email);
}, [resetTrafficMut]);
const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);
const delDepleted = useCallback(() => delDepletedMut.mutateAsync(), [delDepletedMut]);
const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => { const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
if (!client?.email) return null; if (!client?.email) return null;
@ -302,57 +336,53 @@ export function useClients() {
return update(client.email, payload); return update(client.email, payload);
}, [update]); }, [update]);
// WS-driven in-place merges. Page wires these via useWebSocket; the bridge
// covers coarse 'invalidate' and 'inbounds' events centrally.
const queryRef = useRef(query);
queryRef.current = query;
const applyTrafficEvent = useCallback((payload: unknown) => { const applyTrafficEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return; if (!payload || typeof payload !== 'object') return;
const p = payload as { onlineClients?: string[] }; const p = payload as { onlineClients?: string[] };
if (Array.isArray(p.onlineClients)) { if (Array.isArray(p.onlineClients)) {
setOnlines(p.onlineClients); queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
} }
}, []); }, [queryClient]);
const applyClientStatsEvent = useCallback((payload: unknown) => { const applyClientStatsEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return; if (!payload || typeof payload !== 'object') return;
const p = payload as { clients?: ClientTraffic[] & { email?: string }[] }; const p = payload as { clients?: (ClientTraffic & { email?: string })[] };
if (!Array.isArray(p.clients) || p.clients.length === 0) return; if (!Array.isArray(p.clients) || p.clients.length === 0) return;
const byEmail = new Map<string, ClientTraffic>(); const byEmail = new Map<string, ClientTraffic>();
for (const row of p.clients as (ClientTraffic & { email?: string })[]) { for (const row of p.clients) {
if (row && row.email) byEmail.set(row.email, row); if (row && row.email) byEmail.set(row.email, row);
} }
const cur = clientsRef.current || []; queryClient.setQueryData<ClientPageResponse>(keys.clients.list(queryRef.current), (prev) => {
let touched = false; if (!prev) return prev;
const next = cur.slice(); let touched = false;
for (let i = 0; i < next.length; i++) { const next = prev.items.slice();
const row = next[i]; for (let i = 0; i < next.length; i++) {
const upd = byEmail.get(row?.email); const row = next[i];
if (!upd) continue; const upd = byEmail.get(row?.email);
const merged: ClientTraffic = { ...(row.traffic || {}) }; if (!upd) continue;
if (typeof upd.up === 'number') merged.up = upd.up; const merged: ClientTraffic = { ...(row.traffic || {}) };
if (typeof upd.down === 'number') merged.down = upd.down; if (typeof upd.up === 'number') merged.up = upd.up;
if (typeof upd.total === 'number') merged.total = upd.total; if (typeof upd.down === 'number') merged.down = upd.down;
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime; if (typeof upd.total === 'number') merged.total = upd.total;
if (typeof upd.enable === 'boolean') merged.enable = upd.enable; if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline; if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
next[i] = { ...row, traffic: merged }; if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
touched = true; next[i] = { ...row, traffic: merged };
} touched = true;
if (touched) setClients(next); }
}, []); if (!touched) return prev;
return { ...prev, items: next };
const applyInvalidate = useCallback((payload: unknown) => { });
if (!payload || typeof payload !== 'object') return; }, [queryClient]);
const p = payload as { type?: string };
if (p.type !== 'inbounds' && p.type !== 'clients') return;
if (invalidateTimerRef.current != null) clearTimeout(invalidateTimerRef.current);
invalidateTimerRef.current = window.setTimeout(() => {
invalidateTimerRef.current = null;
refresh();
}, 200);
}, [refresh]);
useEffect(() => { useEffect(() => {
Promise.all([refresh(query), fetchSubSettings()]); queryRef.current = query;
// eslint-disable-next-line react-hooks/exhaustive-deps }, [query]);
}, [query, fetchSubSettings]);
return { return {
clients, clients,
@ -386,6 +416,5 @@ export function useClients() {
setEnable, setEnable,
applyTrafficEvent, applyTrafficEvent,
applyClientStatsEvent, applyClientStatsEvent,
applyInvalidate,
}; };
} }

View file

@ -106,14 +106,13 @@ export default function ClientsPage() {
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize, ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
create, update, remove, removeMany, bulkAdjust, attach, detach, create, update, remove, removeMany, bulkAdjust, attach, detach,
resetTraffic, resetAllTraffics, delDepleted, setEnable, resetTraffic, resetAllTraffics, delDepleted, setEnable,
applyTrafficEvent, applyClientStatsEvent, applyInvalidate, applyTrafficEvent, applyClientStatsEvent,
hydrate, hydrate,
} = useClients(); } = useClients();
useWebSocket({ useWebSocket({
traffic: applyTrafficEvent, traffic: applyTrafficEvent,
client_stats: applyClientStatsEvent, client_stats: applyClientStatsEvent,
invalidate: applyInvalidate,
}); });
const [togglingEmail, setTogglingEmail] = useState<string | null>(null); const [togglingEmail, setTogglingEmail] = useState<string | null>(null);