mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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:
parent
864315448e
commit
967b9aba4b
3 changed files with 229 additions and 199 deletions
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue