From 538473b2cc35e49ae1fe0b131ab97c8c49a149e2 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 24 May 2026 17:50:19 +0200 Subject: [PATCH] feat(frontend): introduce TanStack Query with status polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires @tanstack/react-query into every entry and migrates useStatus to useStatusQuery as the foundation for the multi-page MPA → SPA migration. - QueryProvider wraps each entry inside ThemeProvider, with devtools gated on import.meta.env.DEV - Shared queryClient: 30s staleTime, refetchOnWindowFocus, 1 retry - useStatusQuery preserves the { status, fetched, refresh } shape so IndexPage swaps in without further changes - refetchIntervalInBackground:false stops the 2s status poll when the panel tab is hidden, cutting idle traffic against the server --- frontend/package-lock.json | 115 ++++++++++++++++++++- frontend/package.json | 5 +- frontend/src/api/QueryProvider.tsx | 16 +++ frontend/src/api/queries/useStatusQuery.ts | 33 ++++++ frontend/src/api/queryKeys.ts | 5 + frontend/src/entries/api-docs.tsx | 5 +- frontend/src/entries/clients.tsx | 5 +- frontend/src/entries/inbounds.tsx | 5 +- frontend/src/entries/index.tsx | 5 +- frontend/src/entries/login.tsx | 5 +- frontend/src/entries/nodes.tsx | 5 +- frontend/src/entries/settings.tsx | 5 +- frontend/src/entries/subpage.tsx | 5 +- frontend/src/entries/xray.tsx | 5 +- frontend/src/hooks/useStatus.ts | 35 ------- frontend/src/pages/index/IndexPage.tsx | 4 +- frontend/src/queryClient.ts | 14 +++ 17 files changed, 224 insertions(+), 48 deletions(-) create mode 100644 frontend/src/api/QueryProvider.tsx create mode 100644 frontend/src/api/queries/useStatusQuery.ts create mode 100644 frontend/src/api/queryKeys.ts delete mode 100644 frontend/src/hooks/useStatus.ts create mode 100644 frontend/src/queryClient.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ab11d1d5..0d175df1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,8 @@ "@ant-design/icons": "^6.2.3", "@codemirror/lang-json": "^6.0.2", "@codemirror/theme-one-dark": "^6.1.3", + "@tanstack/react-query": "^5.100.14", + "@tanstack/react-query-devtools": "^5.100.14", "antd": "^6.4.3", "axios": "^1.16.1", "codemirror": "^6.0.2", @@ -21,7 +23,8 @@ "qs": "^6.15.2", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-i18next": "^17.0.8" + "react-i18next": "^17.0.8", + "react-router-dom": "^7.15.1" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -1836,6 +1839,59 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/query-core": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", + "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.14.tgz", + "integrity": "sha512-g96SmSSQecYTYcyuAMRXr895GplJv01UGt7qttQWPOUyZ5EGz5tbRc589bMc2m5BsPFD6O0PCEAHdbDYNP6UBw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", + "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.14.tgz", + "integrity": "sha512-JkP5VDgKOw3t/QSA1OABRHEqx8BuNs5MfvZRooNqdvN57SzTuGq3fKR1a2IH5rqa5HDLUm+FOXUEnB9ueHiLzg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.100.14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.100.14", + "react": "^18 || ^19" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2459,6 +2515,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -3917,6 +3986,44 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-router": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", @@ -3976,6 +4083,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index c36aa065..e354c754 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,8 @@ "@ant-design/icons": "^6.2.3", "@codemirror/lang-json": "^6.0.2", "@codemirror/theme-one-dark": "^6.1.3", + "@tanstack/react-query": "^5.100.14", + "@tanstack/react-query-devtools": "^5.100.14", "antd": "^6.4.3", "axios": "^1.16.1", "codemirror": "^6.0.2", @@ -29,7 +31,8 @@ "qs": "^6.15.2", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-i18next": "^17.0.8" + "react-i18next": "^17.0.8", + "react-router-dom": "^7.15.1" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/frontend/src/api/QueryProvider.tsx b/frontend/src/api/QueryProvider.tsx new file mode 100644 index 00000000..71fdc56f --- /dev/null +++ b/frontend/src/api/QueryProvider.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; + +import { queryClient } from '@/queryClient'; + +export function QueryProvider({ children }: { children: ReactNode }) { + return ( + + {children} + {import.meta.env.DEV && ( + + )} + + ); +} diff --git a/frontend/src/api/queries/useStatusQuery.ts b/frontend/src/api/queries/useStatusQuery.ts new file mode 100644 index 00000000..bb33eb50 --- /dev/null +++ b/frontend/src/api/queries/useStatusQuery.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; + +import { HttpUtil } from '@/utils'; +import { Status } from '@/models/status'; +import { keys } from '@/api/queryKeys'; + +const POLL_INTERVAL_MS = 2000; + +async function fetchStatus(): Promise { + const msg = await HttpUtil.get('/panel/api/server/status', undefined, { silent: true }); + if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch status'); + return new Status(msg.obj); +} + +export function useStatusQuery() { + const query = useQuery({ + queryKey: keys.server.status(), + queryFn: fetchStatus, + refetchInterval: POLL_INTERVAL_MS, + refetchIntervalInBackground: false, + staleTime: 0, + }); + + const status = useMemo(() => query.data ?? new Status(), [query.data]); + const refresh = async () => { await query.refetch(); }; + + return { + status, + fetched: query.data !== undefined, + refresh, + }; +} diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts new file mode 100644 index 00000000..c606e53e --- /dev/null +++ b/frontend/src/api/queryKeys.ts @@ -0,0 +1,5 @@ +export const keys = { + server: { + status: () => ['server', 'status'] as const, + }, +} as const; diff --git a/frontend/src/entries/api-docs.tsx b/frontend/src/entries/api-docs.tsx index 9a32c5cd..adbf2aae 100644 --- a/frontend/src/entries/api-docs.tsx +++ b/frontend/src/entries/api-docs.tsx @@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js'; import { applyDocumentTitle } from '@/utils'; import { readyI18n } from '@/i18n/react'; import { ThemeProvider } from '@/hooks/useTheme'; +import { QueryProvider } from '@/api/QueryProvider'; import ApiDocsPage from '@/pages/api-docs/ApiDocsPage'; setupAxios(); @@ -21,7 +22,9 @@ readyI18n().then(() => { if (root) { createRoot(root).render( - + + + , ); } diff --git a/frontend/src/entries/clients.tsx b/frontend/src/entries/clients.tsx index a6834e3f..02fa3d2e 100644 --- a/frontend/src/entries/clients.tsx +++ b/frontend/src/entries/clients.tsx @@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js'; import { applyDocumentTitle } from '@/utils'; import { readyI18n } from '@/i18n/react'; import { ThemeProvider } from '@/hooks/useTheme'; +import { QueryProvider } from '@/api/QueryProvider'; import ClientsPage from '@/pages/clients/ClientsPage'; setupAxios(); @@ -21,7 +22,9 @@ readyI18n().then(() => { if (root) { createRoot(root).render( - + + + , ); } diff --git a/frontend/src/entries/inbounds.tsx b/frontend/src/entries/inbounds.tsx index f59a16e9..d09b5451 100644 --- a/frontend/src/entries/inbounds.tsx +++ b/frontend/src/entries/inbounds.tsx @@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js'; import { applyDocumentTitle } from '@/utils'; import { readyI18n } from '@/i18n/react'; import { ThemeProvider } from '@/hooks/useTheme'; +import { QueryProvider } from '@/api/QueryProvider'; import InboundsPage from '@/pages/inbounds/InboundsPage'; setupAxios(); @@ -21,7 +22,9 @@ readyI18n().then(() => { if (root) { createRoot(root).render( - + + + , ); } diff --git a/frontend/src/entries/index.tsx b/frontend/src/entries/index.tsx index c3620cae..1127f279 100644 --- a/frontend/src/entries/index.tsx +++ b/frontend/src/entries/index.tsx @@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js'; import { applyDocumentTitle } from '@/utils'; import { readyI18n } from '@/i18n/react'; import { ThemeProvider } from '@/hooks/useTheme'; +import { QueryProvider } from '@/api/QueryProvider'; import IndexPage from '@/pages/index/IndexPage'; setupAxios(); @@ -21,7 +22,9 @@ readyI18n().then(() => { if (root) { createRoot(root).render( - + + + , ); } diff --git a/frontend/src/entries/login.tsx b/frontend/src/entries/login.tsx index 66fc4f1a..eecec4bd 100644 --- a/frontend/src/entries/login.tsx +++ b/frontend/src/entries/login.tsx @@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js'; import { applyDocumentTitle } from '@/utils'; import { readyI18n } from '@/i18n/react'; import { ThemeProvider } from '@/hooks/useTheme'; +import { QueryProvider } from '@/api/QueryProvider'; import LoginPage from '@/pages/login/LoginPage'; setupAxios(); @@ -21,7 +22,9 @@ readyI18n().then(() => { if (root) { createRoot(root).render( - + + + , ); } diff --git a/frontend/src/entries/nodes.tsx b/frontend/src/entries/nodes.tsx index 75761eba..82081372 100644 --- a/frontend/src/entries/nodes.tsx +++ b/frontend/src/entries/nodes.tsx @@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js'; import { applyDocumentTitle } from '@/utils'; import { readyI18n } from '@/i18n/react'; import { ThemeProvider } from '@/hooks/useTheme'; +import { QueryProvider } from '@/api/QueryProvider'; import NodesPage from '@/pages/nodes/NodesPage'; setupAxios(); @@ -21,7 +22,9 @@ readyI18n().then(() => { if (root) { createRoot(root).render( - + + + , ); } diff --git a/frontend/src/entries/settings.tsx b/frontend/src/entries/settings.tsx index b6397963..8cb6b9b5 100644 --- a/frontend/src/entries/settings.tsx +++ b/frontend/src/entries/settings.tsx @@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js'; import { applyDocumentTitle } from '@/utils'; import { readyI18n } from '@/i18n/react'; import { ThemeProvider } from '@/hooks/useTheme'; +import { QueryProvider } from '@/api/QueryProvider'; import SettingsPage from '@/pages/settings/SettingsPage'; setupAxios(); @@ -21,7 +22,9 @@ readyI18n().then(() => { if (root) { createRoot(root).render( - + + + , ); } diff --git a/frontend/src/entries/subpage.tsx b/frontend/src/entries/subpage.tsx index fbe6ea75..baaa0e89 100644 --- a/frontend/src/entries/subpage.tsx +++ b/frontend/src/entries/subpage.tsx @@ -4,6 +4,7 @@ import 'antd/dist/reset.css'; import { readyI18n } from '@/i18n/react'; import { ThemeProvider } from '@/hooks/useTheme'; +import { QueryProvider } from '@/api/QueryProvider'; import SubPage from '@/pages/sub/SubPage'; const messageContainer = document.getElementById('message'); @@ -16,7 +17,9 @@ readyI18n().then(() => { if (root) { createRoot(root).render( - + + + , ); } diff --git a/frontend/src/entries/xray.tsx b/frontend/src/entries/xray.tsx index 3b579254..0db35939 100644 --- a/frontend/src/entries/xray.tsx +++ b/frontend/src/entries/xray.tsx @@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js'; import { applyDocumentTitle } from '@/utils'; import { readyI18n } from '@/i18n/react'; import { ThemeProvider } from '@/hooks/useTheme'; +import { QueryProvider } from '@/api/QueryProvider'; import XrayPage from '@/pages/xray/XrayPage'; setupAxios(); @@ -21,7 +22,9 @@ readyI18n().then(() => { if (root) { createRoot(root).render( - + + + , ); } diff --git a/frontend/src/hooks/useStatus.ts b/frontend/src/hooks/useStatus.ts deleted file mode 100644 index 1b9799e5..00000000 --- a/frontend/src/hooks/useStatus.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { HttpUtil } from '@/utils'; -import { Status } from '@/models/status'; - -const POLL_INTERVAL_MS = 2000; - -export function useStatus() { - const [status, setStatus] = useState(() => new Status()); - const [fetched, setFetched] = useState(false); - const fetchedRef = useRef(false); - - const refresh = useCallback(async () => { - try { - const msg = await HttpUtil.get('/panel/api/server/status'); - if (msg?.success) { - setStatus(new Status(msg.obj)); - if (!fetchedRef.current) { - fetchedRef.current = true; - setFetched(true); - } - } - } catch (e) { - console.error('Failed to get status:', e); - } - }, []); - - useEffect(() => { - refresh(); - const timer = window.setInterval(refresh, POLL_INTERVAL_MS); - return () => window.clearInterval(timer); - }, [refresh]); - - return { status, fetched, refresh }; -} diff --git a/frontend/src/pages/index/IndexPage.tsx b/frontend/src/pages/index/IndexPage.tsx index 5334c171..66d431ad 100644 --- a/frontend/src/pages/index/IndexPage.tsx +++ b/frontend/src/pages/index/IndexPage.tsx @@ -36,7 +36,7 @@ import { import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils'; import { useTheme } from '@/hooks/useTheme'; -import { useStatus } from '@/hooks/useStatus'; +import { useStatusQuery } from '@/api/queries/useStatusQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import AppSidebar from '@/components/AppSidebar'; import CustomStatistic from '@/components/CustomStatistic'; @@ -59,7 +59,7 @@ import './IndexPage.css'; export default function IndexPage() { const { t } = useTranslation(); const { isDark, isUltra, antdThemeConfig } = useTheme(); - const { status, fetched, refresh } = useStatus(); + const { status, fetched, refresh } = useStatusQuery(); const { isMobile } = useMediaQuery(); const [messageApi, messageContextHolder] = message.useMessage(); useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); diff --git a/frontend/src/queryClient.ts b/frontend/src/queryClient.ts new file mode 100644 index 00000000..fc46dfa4 --- /dev/null +++ b/frontend/src/queryClient.ts @@ -0,0 +1,14 @@ +import { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: true, + retry: 1, + }, + mutations: { + retry: 0, + }, + }, +});