feat(frontend): route useInbounds fetches through TanStack Query

Rewrites useInbounds so its four server fetches (slim list, default
settings, online clients, last-online map) live in useQuery with
staleTime: Infinity. The in-place WS merge logic for traffic and
client_stats is preserved — applyTrafficEvent / applyClientStatsEvent
still mutate the locally-mirrored dbInbounds so the panel doesn't
refetch every 1-2 seconds when stats stream in.

refresh() becomes a thin invalidateQueries on the three list keys,
which mutations in the page already call after add/edit/del.

The bridge now forwards the WebSocket 'inbounds' push to
setQueryData(['inbounds', 'slim']), and InboundsPage drops its
useEffect(fetchDefaultSettings → refresh) plus the invalidate /
inbounds wiring on useWebSocket — both are owned by the bridge now.
This commit is contained in:
MHSanaei 2026-05-24 18:59:35 +02:00
parent bbb7af65f6
commit 864315448e
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
4 changed files with 156 additions and 122 deletions

View file

@ -9,5 +9,15 @@ export const keys = {
settings: {
root: () => ['settings'] as const,
all: () => ['settings', 'all'] as const,
defaults: () => ['settings', 'defaults'] as const,
},
inbounds: {
root: () => ['inbounds'] as const,
slim: () => ['inbounds', 'slim'] as const,
},
clients: {
root: () => ['clients'] as const,
onlines: () => ['clients', 'onlines'] as const,
lastOnline: () => ['clients', 'lastOnline'] as const,
},
} as const;

View file

@ -52,15 +52,22 @@ export function useWebSocketBridge() {
queryClient.setQueryData(keys.nodes.list(), payload);
};
const onInbounds: Handler = (payload) => {
if (!Array.isArray(payload)) return;
queryClient.setQueryData(keys.inbounds.slim(), payload);
};
client.on('invalidate', onInvalidate);
client.on('outbounds', onOutbounds);
client.on('nodes', onNodes);
client.on('inbounds', onInbounds);
client.connect();
return () => {
client.off('invalidate', onInvalidate);
client.off('outbounds', onOutbounds);
client.off('nodes', onNodes);
client.off('inbounds', onInbounds);
if (invalidateTimer != null) {
clearTimeout(invalidateTimer);
invalidateTimer = null;

View file

@ -74,11 +74,8 @@ export default function InboundsPage() {
remarkModel,
refresh,
hydrateInbound,
fetchDefaultSettings,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
applyInboundsEvent,
} = useInbounds();
const [modal, modalContextHolder] = Modal.useModal();
@ -105,15 +102,8 @@ export default function InboundsPage() {
useWebSocket({
traffic: applyTrafficEvent,
client_stats: applyClientStatsEvent,
invalidate: applyInvalidate,
inbounds: applyInboundsEvent,
});
useEffect(() => {
fetchDefaultSettings().then(() => refresh());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [formDbInbound, setFormDbInbound] = useState<any>(null);

View file

@ -1,9 +1,11 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { DBInbound } from '@/models/dbinbound.js';
import { Protocols } from '@/models/inbound.js';
import { setDatepicker } from '@/hooks/useDatepicker';
import { keys } from '@/api/queryKeys';
export interface SubSettings {
enable: boolean;
@ -25,6 +27,27 @@ interface ClientRollup {
comments: Map<string, string>;
}
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
msg?: string;
}
interface DefaultsPayload {
expireDiff?: number;
trafficDiff?: number;
tgBotEnable?: boolean;
subEnable?: boolean;
subTitle?: string;
subURI?: string;
subJsonURI?: string;
subJsonEnable?: boolean;
pageSize?: number;
remarkModel?: string;
datepicker?: string;
ipLimitEnable?: boolean;
}
const TRACKED_PROTOCOLS = [
Protocols.VMESS,
Protocols.VLESS,
@ -33,40 +56,98 @@ const TRACKED_PROTOCOLS = [
Protocols.HYSTERIA,
];
async function fetchSlimInbounds(): Promise<unknown[]> {
const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true }) as ApiMsg<unknown[]>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbounds');
return Array.isArray(msg.obj) ? msg.obj : [];
}
async function fetchOnlineClients(): Promise<string[]> {
const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg<string[]>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
return Array.isArray(msg.obj) ? msg.obj : [];
}
async function fetchLastOnlineMap(): Promise<Record<string, number>> {
const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true }) as ApiMsg<Record<string, number>>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
return (msg.obj && typeof msg.obj === 'object') ? msg.obj : {};
}
async function fetchDefaultSettings(): Promise<DefaultsPayload> {
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg<DefaultsPayload>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
return (msg.obj as DefaultsPayload) || {};
}
export function useInbounds() {
const [fetched, setFetched] = useState(false);
const refreshingRef = useRef(false);
const queryClient = useQueryClient();
const slimQuery = useQuery({
queryKey: keys.inbounds.slim(),
queryFn: fetchSlimInbounds,
staleTime: Infinity,
});
const onlinesQuery = useQuery({
queryKey: keys.clients.onlines(),
queryFn: fetchOnlineClients,
staleTime: Infinity,
});
const lastOnlineQuery = useQuery({
queryKey: keys.clients.lastOnline(),
queryFn: fetchLastOnlineMap,
staleTime: Infinity,
});
const defaultsQuery = useQuery({
queryKey: keys.settings.defaults(),
queryFn: fetchDefaultSettings,
staleTime: Infinity,
});
const defaults = defaultsQuery.data ?? {};
const expireDiff = (defaults.expireDiff ?? 0) * 86400000;
const trafficDiff = (defaults.trafficDiff ?? 0) * 1073741824;
const tgBotEnable = !!defaults.tgBotEnable;
const ipLimitEnable = !!defaults.ipLimitEnable;
const pageSize = defaults.pageSize ?? 0;
const remarkModel = defaults.remarkModel || '-ieo';
const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
const subSettings: SubSettings = useMemo(() => ({
enable: !!defaults.subEnable,
subTitle: defaults.subTitle || '',
subURI: defaults.subURI || '',
subJsonURI: defaults.subJsonURI || '',
subJsonEnable: !!defaults.subJsonEnable,
}), [defaults.subEnable, defaults.subTitle, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable]);
useEffect(() => {
if (defaults.datepicker) setDatepicker(datepicker);
}, [datepicker, defaults.datepicker]);
const expireDiffRef = useRef(expireDiff);
expireDiffRef.current = expireDiff;
const trafficDiffRef = useRef(trafficDiff);
trafficDiffRef.current = trafficDiff;
// dbInbounds mirrors the slim query data wrapped as DBInbound instances, but
// stays mutable so the WS-driven applyClientStatsEvent / applyTrafficEvent
// can merge per-row updates without invalidating the entire query.
const [dbInbounds, setDbInbounds] = useState<DBInboundInstance[]>([]);
const dbInboundsRef = useRef<DBInboundInstance[]>([]);
dbInboundsRef.current = dbInbounds;
const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
const [statsVersion, setStatsVersion] = useState(0);
const [onlineClients, setOnlineClients] = useState<string[]>([]);
const onlineClientsRef = useRef<string[]>([]);
onlineClientsRef.current = onlineClients;
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
const [statsVersion, setStatsVersion] = useState(0);
const [expireDiff, setExpireDiff] = useState(0);
const expireDiffRef = useRef(0);
expireDiffRef.current = expireDiff;
const [trafficDiff, setTrafficDiff] = useState(0);
const trafficDiffRef = useRef(0);
trafficDiffRef.current = trafficDiff;
const [subSettings, setSubSettings] = useState<SubSettings>({
enable: false,
subTitle: '',
subURI: '',
subJsonURI: '',
subJsonEnable: false,
});
const [remarkModel, setRemarkModel] = useState('-ieo');
const [datepicker, setDatepickerState] = useState('gregorian');
const [tgBotEnable, setTgBotEnable] = useState(false);
const [ipLimitEnable, setIpLimitEnable] = useState(false);
const [pageSize, setPageSize] = useState(0);
const rollupClients = useCallback(
(dbInbound: DBInboundInstance, inbound: { clients?: { email?: string; enable?: boolean; comment?: string }[] }): ClientRollup => {
@ -130,27 +211,6 @@ export function useInbounds() {
[],
);
const setInbounds = useCallback(
(rows: unknown[]) => {
const next: DBInboundInstance[] = [];
const counts: Record<number, ClientRollup> = {};
for (const row of rows as { protocol: string; id: number }[]) {
const dbInbound = new DBInbound(row) as DBInboundInstance;
const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean } }).toInbound();
next.push(dbInbound);
if (TRACKED_PROTOCOLS.includes(row.protocol)) {
if ((dbInbound as unknown as { isSS: boolean }).isSS && !parsed.isSSMultiUser) continue;
counts[row.id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
}
}
dbInboundsRef.current = next;
setDbInbounds(next);
setClientCount(counts);
setFetched(true);
},
[rollupClients],
);
const rebuildClientCount = useCallback(() => {
const counts: Record<number, ClientRollup> = {};
for (const dbInbound of dbInboundsRef.current) {
@ -164,57 +224,46 @@ export function useInbounds() {
setClientCount(counts);
}, [rollupClients]);
const fetchOnlineUsers = useCallback(async () => {
const msg = await HttpUtil.post('/panel/api/clients/onlines');
if (msg?.success) {
const list = (msg.obj || []) as string[];
onlineClientsRef.current = list;
setOnlineClients(list);
// Seed dbInbounds + clientCount from the slim query. Runs on first fetch and
// again every time the query refetches (e.g. invalidate from WS bridge).
useEffect(() => {
if (!slimQuery.data) return;
const next: DBInboundInstance[] = [];
const counts: Record<number, ClientRollup> = {};
for (const row of slimQuery.data as { protocol: string; id: number }[]) {
const dbInbound = new DBInbound(row) as DBInboundInstance;
const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean } }).toInbound();
next.push(dbInbound);
if (TRACKED_PROTOCOLS.includes(row.protocol)) {
if ((dbInbound as unknown as { isSS: boolean }).isSS && !parsed.isSSMultiUser) continue;
counts[row.id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
}
}
}, []);
dbInboundsRef.current = next;
setDbInbounds(next);
setClientCount(counts);
}, [slimQuery.data, rollupClients]);
const fetchLastOnlineMap = useCallback(async () => {
const msg = await HttpUtil.post('/panel/api/clients/lastOnline');
if (msg?.success && msg.obj) {
setLastOnlineMap(msg.obj as Record<string, number>);
useEffect(() => {
if (onlinesQuery.data) {
onlineClientsRef.current = onlinesQuery.data;
setOnlineClients(onlinesQuery.data);
}
}, []);
}, [onlinesQuery.data]);
const fetchDefaultSettings = useCallback(async () => {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (!msg?.success) return;
const s = (msg.obj || {}) as Record<string, unknown>;
setExpireDiff((s.expireDiff as number ?? 0) * 86400000);
setTrafficDiff((s.trafficDiff as number ?? 0) * 1073741824);
setTgBotEnable(!!s.tgBotEnable);
setSubSettings({
enable: !!s.subEnable,
subTitle: (s.subTitle as string) || '',
subURI: (s.subURI as string) || '',
subJsonURI: (s.subJsonURI as string) || '',
subJsonEnable: !!s.subJsonEnable,
});
setPageSize((s.pageSize as number) ?? 0);
setRemarkModel((s.remarkModel as string) || '-ieo');
const dp = ((s.datepicker as string) || 'gregorian') as 'gregorian' | 'jalalian';
setDatepickerState(dp);
setDatepicker(dp);
setIpLimitEnable(!!s.ipLimitEnable);
}, []);
useEffect(() => {
if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
}, [lastOnlineQuery.data]);
const fetched = slimQuery.data !== undefined && defaultsQuery.data !== undefined;
const refresh = useCallback(async () => {
if (refreshingRef.current) return;
refreshingRef.current = true;
try {
const msg = await HttpUtil.get('/panel/api/inbounds/list/slim');
if (!msg?.success) return;
await fetchLastOnlineMap();
await fetchOnlineUsers();
setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
} finally {
window.setTimeout(() => { refreshingRef.current = false; }, 500);
}
}, [fetchLastOnlineMap, fetchOnlineUsers, setInbounds]);
await Promise.all([
queryClient.invalidateQueries({ queryKey: keys.inbounds.slim() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
]);
}, [queryClient]);
// hydrateInbound fetches the full inbound (including settings.clients with
// uuid/password/flow/etc.) and swaps it into the cached list. Use this
@ -313,25 +362,6 @@ export function useInbounds() {
[rebuildClientCount],
);
const applyInvalidate = useCallback(
(payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { type?: string };
if (p.type === 'inbounds') {
refresh();
}
},
[refresh],
);
const applyInboundsEvent = useCallback(
(payload: unknown) => {
if (!Array.isArray(payload)) return;
setInbounds(payload);
},
[setInbounds],
);
const totals = useMemo(() => {
let up = 0;
let down = 0;
@ -361,10 +391,7 @@ export function useInbounds() {
pageSize,
refresh,
hydrateInbound,
fetchDefaultSettings,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
applyInboundsEvent,
};
}