feat(frontend): introduce TanStack Query with status polling

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
This commit is contained in:
MHSanaei 2026-05-24 17:50:19 +02:00
parent 867a145979
commit 538473b2cc
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
17 changed files with 224 additions and 48 deletions

View file

@ -11,6 +11,8 @@
"@ant-design/icons": "^6.2.3", "@ant-design/icons": "^6.2.3",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-query-devtools": "^5.100.14",
"antd": "^6.4.3", "antd": "^6.4.3",
"axios": "^1.16.1", "axios": "^1.16.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
@ -21,7 +23,8 @@
"qs": "^6.15.2", "qs": "^6.15.2",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^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": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@ -1836,6 +1839,59 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@tybys/wasm-util": {
"version": "0.10.2", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@ -2459,6 +2515,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/crelt": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
@ -3917,6 +3986,44 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT" "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": { "node_modules/rolldown": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
@ -3976,6 +4083,12 @@
"semver": "bin/semver.js" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View file

@ -19,6 +19,8 @@
"@ant-design/icons": "^6.2.3", "@ant-design/icons": "^6.2.3",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-query-devtools": "^5.100.14",
"antd": "^6.4.3", "antd": "^6.4.3",
"axios": "^1.16.1", "axios": "^1.16.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
@ -29,7 +31,8 @@
"qs": "^6.15.2", "qs": "^6.15.2",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^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": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",

View file

@ -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 (
<QueryClientProvider client={queryClient}>
{children}
{import.meta.env.DEV && (
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
)}
</QueryClientProvider>
);
}

View file

@ -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<Status> {
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,
};
}

View file

@ -0,0 +1,5 @@
export const keys = {
server: {
status: () => ['server', 'status'] as const,
},
} as const;

View file

@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react'; import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider';
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage'; import ApiDocsPage from '@/pages/api-docs/ApiDocsPage';
setupAxios(); setupAxios();
@ -21,7 +22,9 @@ readyI18n().then(() => {
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
<ThemeProvider> <ThemeProvider>
<ApiDocsPage /> <QueryProvider>
<ApiDocsPage />
</QueryProvider>
</ThemeProvider>, </ThemeProvider>,
); );
} }

View file

@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react'; import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider';
import ClientsPage from '@/pages/clients/ClientsPage'; import ClientsPage from '@/pages/clients/ClientsPage';
setupAxios(); setupAxios();
@ -21,7 +22,9 @@ readyI18n().then(() => {
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
<ThemeProvider> <ThemeProvider>
<ClientsPage /> <QueryProvider>
<ClientsPage />
</QueryProvider>
</ThemeProvider>, </ThemeProvider>,
); );
} }

View file

@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react'; import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider';
import InboundsPage from '@/pages/inbounds/InboundsPage'; import InboundsPage from '@/pages/inbounds/InboundsPage';
setupAxios(); setupAxios();
@ -21,7 +22,9 @@ readyI18n().then(() => {
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
<ThemeProvider> <ThemeProvider>
<InboundsPage /> <QueryProvider>
<InboundsPage />
</QueryProvider>
</ThemeProvider>, </ThemeProvider>,
); );
} }

View file

@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react'; import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider';
import IndexPage from '@/pages/index/IndexPage'; import IndexPage from '@/pages/index/IndexPage';
setupAxios(); setupAxios();
@ -21,7 +22,9 @@ readyI18n().then(() => {
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
<ThemeProvider> <ThemeProvider>
<IndexPage /> <QueryProvider>
<IndexPage />
</QueryProvider>
</ThemeProvider>, </ThemeProvider>,
); );
} }

View file

@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react'; import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider';
import LoginPage from '@/pages/login/LoginPage'; import LoginPage from '@/pages/login/LoginPage';
setupAxios(); setupAxios();
@ -21,7 +22,9 @@ readyI18n().then(() => {
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
<ThemeProvider> <ThemeProvider>
<LoginPage /> <QueryProvider>
<LoginPage />
</QueryProvider>
</ThemeProvider>, </ThemeProvider>,
); );
} }

View file

@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react'; import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider';
import NodesPage from '@/pages/nodes/NodesPage'; import NodesPage from '@/pages/nodes/NodesPage';
setupAxios(); setupAxios();
@ -21,7 +22,9 @@ readyI18n().then(() => {
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
<ThemeProvider> <ThemeProvider>
<NodesPage /> <QueryProvider>
<NodesPage />
</QueryProvider>
</ThemeProvider>, </ThemeProvider>,
); );
} }

View file

@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react'; import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider';
import SettingsPage from '@/pages/settings/SettingsPage'; import SettingsPage from '@/pages/settings/SettingsPage';
setupAxios(); setupAxios();
@ -21,7 +22,9 @@ readyI18n().then(() => {
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
<ThemeProvider> <ThemeProvider>
<SettingsPage /> <QueryProvider>
<SettingsPage />
</QueryProvider>
</ThemeProvider>, </ThemeProvider>,
); );
} }

View file

@ -4,6 +4,7 @@ import 'antd/dist/reset.css';
import { readyI18n } from '@/i18n/react'; import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider';
import SubPage from '@/pages/sub/SubPage'; import SubPage from '@/pages/sub/SubPage';
const messageContainer = document.getElementById('message'); const messageContainer = document.getElementById('message');
@ -16,7 +17,9 @@ readyI18n().then(() => {
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
<ThemeProvider> <ThemeProvider>
<SubPage /> <QueryProvider>
<SubPage />
</QueryProvider>
</ThemeProvider>, </ThemeProvider>,
); );
} }

View file

@ -6,6 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react'; import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider';
import XrayPage from '@/pages/xray/XrayPage'; import XrayPage from '@/pages/xray/XrayPage';
setupAxios(); setupAxios();
@ -21,7 +22,9 @@ readyI18n().then(() => {
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
<ThemeProvider> <ThemeProvider>
<XrayPage /> <QueryProvider>
<XrayPage />
</QueryProvider>
</ThemeProvider>, </ThemeProvider>,
); );
} }

View file

@ -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<Status>(() => 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 };
}

View file

@ -36,7 +36,7 @@ import {
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils'; import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { useStatus } from '@/hooks/useStatus'; import { useStatusQuery } from '@/api/queries/useStatusQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import AppSidebar from '@/components/AppSidebar'; import AppSidebar from '@/components/AppSidebar';
import CustomStatistic from '@/components/CustomStatistic'; import CustomStatistic from '@/components/CustomStatistic';
@ -59,7 +59,7 @@ import './IndexPage.css';
export default function IndexPage() { export default function IndexPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, antdThemeConfig } = useTheme();
const { status, fetched, refresh } = useStatus(); const { status, fetched, refresh } = useStatusQuery();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);

View file

@ -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,
},
},
});