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