mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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:
parent
867a145979
commit
538473b2cc
17 changed files with 224 additions and 48 deletions
115
frontend/package-lock.json
generated
115
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
16
frontend/src/api/QueryProvider.tsx
Normal file
16
frontend/src/api/QueryProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/api/queries/useStatusQuery.ts
Normal file
33
frontend/src/api/queries/useStatusQuery.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
5
frontend/src/api/queryKeys.ts
Normal file
5
frontend/src/api/queryKeys.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const keys = {
|
||||
server: {
|
||||
status: () => ['server', 'status'] as const,
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -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(
|
||||
<ThemeProvider>
|
||||
<ApiDocsPage />
|
||||
<QueryProvider>
|
||||
<ApiDocsPage />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ThemeProvider>
|
||||
<ClientsPage />
|
||||
<QueryProvider>
|
||||
<ClientsPage />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ThemeProvider>
|
||||
<InboundsPage />
|
||||
<QueryProvider>
|
||||
<InboundsPage />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ThemeProvider>
|
||||
<IndexPage />
|
||||
<QueryProvider>
|
||||
<IndexPage />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ThemeProvider>
|
||||
<LoginPage />
|
||||
<QueryProvider>
|
||||
<LoginPage />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ThemeProvider>
|
||||
<NodesPage />
|
||||
<QueryProvider>
|
||||
<NodesPage />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ThemeProvider>
|
||||
<SettingsPage />
|
||||
<QueryProvider>
|
||||
<SettingsPage />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ThemeProvider>
|
||||
<SubPage />
|
||||
<QueryProvider>
|
||||
<SubPage />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ThemeProvider>
|
||||
<XrayPage />
|
||||
<QueryProvider>
|
||||
<XrayPage />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
14
frontend/src/queryClient.ts
Normal file
14
frontend/src/queryClient.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue