From 864315448eeced124d20736b9669ebf45e0da8e2 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 24 May 2026 18:59:35 +0200 Subject: [PATCH] feat(frontend): route useInbounds fetches through TanStack Query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/api/queryKeys.ts | 10 + frontend/src/api/websocketBridge.ts | 7 + frontend/src/pages/inbounds/InboundsPage.tsx | 10 - frontend/src/pages/inbounds/useInbounds.ts | 251 ++++++++++--------- 4 files changed, 156 insertions(+), 122 deletions(-) diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 8f431ef6..065762c3 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -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; diff --git a/frontend/src/api/websocketBridge.ts b/frontend/src/api/websocketBridge.ts index db738d59..5edfd34d 100644 --- a/frontend/src/api/websocketBridge.ts +++ b/frontend/src/api/websocketBridge.ts @@ -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; diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index 95e994c7..7cd570c5 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -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(null); diff --git a/frontend/src/pages/inbounds/useInbounds.ts b/frontend/src/pages/inbounds/useInbounds.ts index aacca078..6a5ff89a 100644 --- a/frontend/src/pages/inbounds/useInbounds.ts +++ b/frontend/src/pages/inbounds/useInbounds.ts @@ -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; } +interface ApiMsg { + 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 { + const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true }) as ApiMsg; + if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbounds'); + return Array.isArray(msg.obj) ? msg.obj : []; +} + +async function fetchOnlineClients(): Promise { + const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg; + if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines'); + return Array.isArray(msg.obj) ? msg.obj : []; +} + +async function fetchLastOnlineMap(): Promise> { + const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true }) as ApiMsg>; + 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 { + const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg; + 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([]); const dbInboundsRef = useRef([]); dbInboundsRef.current = dbInbounds; const [clientCount, setClientCount] = useState>({}); + const [statsVersion, setStatsVersion] = useState(0); + const [onlineClients, setOnlineClients] = useState([]); const onlineClientsRef = useRef([]); onlineClientsRef.current = onlineClients; const [lastOnlineMap, setLastOnlineMap] = useState>({}); - 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({ - 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 = {}; - 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 = {}; 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 = {}; + 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); + 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; - 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, }; }