mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-28 14:07:13 +00:00
feat(frontend): TanStack Query + React Router migration & in-panel API docs (#4541)
* 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
* feat(frontend): collapse panel pages into a single React Router SPA
Replaces the 7-entry MPA shell (index/clients/inbounds/nodes/settings/
xray/api-docs HTML files) with one main.tsx + createBrowserRouter. The
Go backend now serves the same index.html for every authenticated
panel route; React Router reads the URL and mounts the page from cache
on subsequent navigation — no more full reloads between tabs.
Frontend
- main.tsx: single bootstrap (setupAxios, i18n, ThemeProvider,
QueryProvider, RouterProvider) replacing 7 near-duplicate entries
- routes.tsx: declarative router with lazy()-loaded pages, basename
derived from window.X_UI_BASE_PATH so panels at /secret/panel work
- layouts/PanelLayout.tsx: shell mount-point for the WS → queryClient
bridge so connection survives navigation
- api/websocketBridge.ts: subscribes the singleton WebSocketClient to
queryClient and dispatches invalidate/outbounds events to cached
queries (page-level useWebSocket handlers stay until Phase 3 hooks
migrate)
- AppSidebar: navigates via useNavigate + useLocation instead of
window.location.href; drops basePath/requestUri props
- Pages: drop the unused basePath/requestUri locals exposed only for
the old sidebar
Build
- vite.config: 9 rollup inputs → 3 (index, login, subpage). Dev proxy
bypass collapses /panel/* to index.html and skips API prefixes
- vendor-tanstack + vendor-router chunks added to manualChunks
Backend
- xui.go: 7 per-page HTML handlers → one panelSPA handler serving
index.html for /, /inbounds, /clients, /nodes, /settings, /xray,
/api-docs. The /panel/api, /panel/setting, /panel/xray sub-routers
are untouched
* feat(frontend): migrate useNodes to TanStack Query
Splits the hand-rolled useNodes hook into useNodesQuery (server data +
NodeRecord type + derived totals) and useNodeMutations (add/update/del/
setEnable/probe/test). Mutations invalidate ['nodes'] on success, so
the list refreshes without each call awaiting a manual refresh().
NodesPage drops useWebSocket({ nodes: applyNodesEvent }) — the
WebSocket → query bridge now forwards the 'nodes' push to
setQueryData(['nodes', 'list']) once at the SPA root.
InboundsPage and the inbound form/list components import NodeRecord
from its new home next to the query hook.
* feat(frontend): migrate useAllSetting to TanStack Query
Replaces the hand-rolled fetch + dirty-tracking hook with useAllSettings
backed by useQuery + useMutation. The draft (current edits) is kept in
local state and reset whenever query.data lands. saveAll posts the
draft via a mutation; on success, invalidating ['settings'] refetches
and the useEffect resets the draft so saveDisabled flips back to true.
staleTime: Infinity prevents refetchOnWindowFocus from clobbering
in-flight edits — settings only change in response to this user's own
save.
setSpinning stays as a pass-through to a local flag so the existing
restartPanel flow in SettingsPage keeps showing its spinner.
* feat(frontend): route useInbounds fetches through TanStack Query
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.
* feat(frontend): migrate useClients to TanStack Query
Replaces 12 hand-rolled mutation callbacks and a tangle of useState +
useRef + useEffect with one useQuery (paged list) + nine useMutation
wrappers. The list query uses keepPreviousData so paging/filter
changes don't blank the table mid-fetch.
The setQuery shallow-compare logic is preserved for backward
compatibility with ClientsPage's effect that rebuilds the params on
every render. Internally setQuery only updates state when the params
actually differ — Query's queryKey equality handles the rest.
WS-driven applyTrafficEvent / applyClientStatsEvent now mutate the
query cache via setQueryData(['clients', 'list', currentParams]) so
per-second stats updates skip a full refetch. applyInvalidate is gone
from the hook — the bridge owns coarse 'clients' invalidation.
ClientsPage drops the invalidate handler from its useWebSocket
subscription; auxiliary queries (inboundOptions, defaults, onlines)
load via TanStack Query and are shared with useInbounds via the same
query keys.
* feat(frontend): route useXraySetting fetches through TanStack Query
Keeps the bidirectional xraySetting ↔ templateSettings editor sync and
the 1s dirty-tracking interval intact (those are local editor state,
not server data). All seven server calls move:
- config + traffic → useQuery on ['xray', 'config'] and
['xray', 'outboundsTraffic']
- saveAll → useMutation that invalidates the config query
- resetOutboundsTraffic → useMutation that invalidates the traffic
query
- restartXray → useMutation (fires the restart, then reads the
result string)
- resetToDefault → useMutation (fetch default config, push it into
the editor via setTemplateSettings)
The WebSocket 'outbounds' event already lands in
keys.xray.outboundsTraffic() via the bridge, so XrayPage drops its
useWebSocket({ outbounds: applyOutboundsEvent }) wiring entirely and
the hook no longer exposes applyOutboundsEvent.
A useEffect seeds xraySetting / templateSettings / tags / test URL
from query data on first fetch and on every refetch, mirroring what
the original fetchAll() did.
* fix(frontend): restore per-route document titles in the SPA
When the multi-entry MPA collapsed into a single index.html, every
route inherited the static <title>3X-UI</title> from the shared shell,
so every panel page showed "hostname - 3X-UI" instead of the original
"hostname - Overview / Clients / Inbounds / ...".
usePageTitle reads the current pathname and rewrites document.title
on every navigation, matching the titles the deleted *.html files
used to carry. Mounted in PanelLayout so it covers all panel routes
without each page having to opt in.
The startup applyDocumentTitle() call in main.tsx is gone — the hook
sets the full "hostname - PageTitle" string itself.
* feat(api-docs): expose OpenAPI spec + render Swagger UI in panel
Replaces the hand-rolled API docs UI with industry-standard tooling so
external integrations (Postman, Insomnia, openapi-generator) can
consume the panel API without parsing endpoints.js by hand.
Generator
- frontend/scripts/build-openapi.mjs: walks the existing endpoints.js
(still the single source of truth) and emits an OpenAPI 3.0.3 spec
at frontend/public/openapi.json. Handles Gin :param → {param} path
translation, body / query / path parameter splits, 200 + error
response examples, and Bearer + cookie security schemes
- npm run build now runs gen:api before vite build, so the spec is
always in sync with what's documented
Backend
- web/controller/dist.go exposes ServeOpenAPISpec which streams the
embedded dist/openapi.json with a short Cache-Control. Public
endpoint (no auth) so Postman can fetch it without first logging in
- web/web.go wires GET /panel/api/openapi.json before the auth-gated
/panel/api router
Panel
- ApiDocsPage now renders swagger-ui-react fed by the basePath-aware
openapi.json URL. Dark mode is overridden via CSS targeting the
Swagger UI internals
- CodeBlock / EndpointRow / EndpointSection are gone; the swagger-ui
vendor chunk (134 KB gzipped) only loads on this lazy route, not on
every panel page
- vite.config: vendor-swagger manualChunk keeps the new dep out of
the main vendor bundle
For Postman: import http://<panel>/panel/api/openapi.json. Everything
from /login + /panel/api/* shows up with auth, params, and examples.
* style(api-docs): dark/ultra theme for Swagger UI
Override every visual surface Swagger does not theme on its own:
opblocks, tables, model boxes, form inputs, code blocks, modals,
Servers dropdown, per-endpoint padlocks and expand chevrons. Replaces
Swagger's default light-arrow chevron on selects with a light-fill SVG
positioned at the corner so the dark background-color is visible.
Also disables deepLinking to silence the noisy v4 underscore warning;
not used in our panel.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
|
||||
const DIRTY_POLL_MS = 1000;
|
||||
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
||||
|
||||
export interface OutboundTrafficRow {
|
||||
tag: string;
|
||||
@@ -70,7 +73,6 @@ export interface UseXraySettingResult {
|
||||
fetchAll: () => Promise<void>;
|
||||
fetchOutboundsTraffic: () => Promise<void>;
|
||||
resetOutboundsTraffic: (tag: string) => Promise<void>;
|
||||
applyOutboundsEvent: (payload: unknown) => void;
|
||||
testOutbound: (
|
||||
index: number,
|
||||
outbound: unknown,
|
||||
@@ -82,18 +84,59 @@ export interface UseXraySettingResult {
|
||||
restartXray: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
obj?: T;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
interface XrayConfigPayload {
|
||||
xraySetting: XraySettingsValue;
|
||||
inboundTags?: string[];
|
||||
clientReverseTags?: string[];
|
||||
outboundTestUrl?: string;
|
||||
}
|
||||
|
||||
async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
||||
const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true }) as ApiMsg<string>;
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
|
||||
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
|
||||
try {
|
||||
return JSON.parse(msg.obj) as XrayConfigPayload;
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
throw new Error(`Malformed xray config response: ${err.message}`, { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
|
||||
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true }) as ApiMsg<OutboundTrafficRow[]>;
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
|
||||
return Array.isArray(msg.obj) ? msg.obj : [];
|
||||
}
|
||||
|
||||
export function useXraySetting(): UseXraySettingResult {
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const configQuery = useQuery({
|
||||
queryKey: keys.xray.config(),
|
||||
queryFn: fetchXrayConfig,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const trafficQuery = useQuery({
|
||||
queryKey: keys.xray.outboundsTraffic(),
|
||||
queryFn: fetchOutboundsTraffic,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const [saveDisabled, setSaveDisabled] = useState(true);
|
||||
const [fetchError, setFetchError] = useState('');
|
||||
const [xraySetting, setXraySettingState] = useState('');
|
||||
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
||||
const [outboundTestUrl, setOutboundTestUrlState] = useState('https://www.google.com/generate_204');
|
||||
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
|
||||
const [inboundTags, setInboundTags] = useState<string[]>([]);
|
||||
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
||||
const [restartResult, setRestartResult] = useState('');
|
||||
const [outboundsTraffic, setOutboundsTraffic] = useState<OutboundTrafficRow[]>([]);
|
||||
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
|
||||
const [testingAll, setTestingAll] = useState(false);
|
||||
|
||||
@@ -108,6 +151,28 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
outboundTestUrlRef.current = outboundTestUrl;
|
||||
templateSettingsRef.current = templateSettings;
|
||||
|
||||
// Seed local editor state from the config query. Runs on first fetch and
|
||||
// every time the query refetches (e.g. after a successful save).
|
||||
useEffect(() => {
|
||||
if (!configQuery.data) return;
|
||||
const obj = configQuery.data;
|
||||
const pretty = JSON.stringify(obj.xraySetting, null, 2);
|
||||
syncingRef.current = true;
|
||||
setXraySettingState(pretty);
|
||||
setTemplateSettingsState(obj.xraySetting);
|
||||
oldXraySettingRef.current = pretty;
|
||||
syncingRef.current = false;
|
||||
setInboundTags(obj.inboundTags || []);
|
||||
setClientReverseTags(obj.clientReverseTags || []);
|
||||
const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
|
||||
setOutboundTestUrlState(nextUrl);
|
||||
oldOutboundTestUrlRef.current = nextUrl;
|
||||
setSaveDisabled(true);
|
||||
}, [configQuery.data]);
|
||||
|
||||
const fetched = configQuery.data !== undefined || configQuery.isError;
|
||||
const fetchError = configQuery.error ? (configQuery.error as Error).message : '';
|
||||
|
||||
const setXraySetting = useCallback((next: string) => {
|
||||
setXraySettingState(next);
|
||||
if (syncingRef.current) return;
|
||||
@@ -142,63 +207,59 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setFetchError('');
|
||||
const msg = await HttpUtil.post('/panel/xray/');
|
||||
if (!msg?.success) {
|
||||
setFetchError(msg?.msg || 'Failed to load xray config');
|
||||
setFetched(true);
|
||||
return;
|
||||
}
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(msg.obj);
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
setFetchError(`Malformed xray config response: ${err?.message || String(err)}`);
|
||||
setFetched(true);
|
||||
return;
|
||||
}
|
||||
const pretty = JSON.stringify(obj.xraySetting, null, 2);
|
||||
syncingRef.current = true;
|
||||
setXraySettingState(pretty);
|
||||
setTemplateSettingsState(obj.xraySetting);
|
||||
oldXraySettingRef.current = pretty;
|
||||
syncingRef.current = false;
|
||||
setInboundTags(obj.inboundTags || []);
|
||||
setClientReverseTags(obj.clientReverseTags || []);
|
||||
const nextUrl = obj.outboundTestUrl || 'https://www.google.com/generate_204';
|
||||
setOutboundTestUrlState(nextUrl);
|
||||
oldOutboundTestUrlRef.current = nextUrl;
|
||||
setFetched(true);
|
||||
setSaveDisabled(true);
|
||||
}, []);
|
||||
await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
||||
}, [queryClient]);
|
||||
|
||||
const saveAll = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/update', {
|
||||
const fetchOutboundsTrafficCb = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
||||
}, [queryClient]);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () =>
|
||||
HttpUtil.post('/panel/xray/update', {
|
||||
xraySetting: xraySettingRef.current,
|
||||
outboundTestUrl: outboundTestUrlRef.current || 'https://www.google.com/generate_204',
|
||||
});
|
||||
if (msg?.success) await fetchAll();
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, [fetchAll]);
|
||||
outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL,
|
||||
}) as Promise<ApiMsg>,
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
||||
},
|
||||
});
|
||||
|
||||
const fetchOutboundsTraffic = useCallback(async () => {
|
||||
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic');
|
||||
if (msg?.success) setOutboundsTraffic(msg.obj || []);
|
||||
}, []);
|
||||
const resetTrafficMut = useMutation({
|
||||
mutationFn: (tag: string) =>
|
||||
HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }) as Promise<ApiMsg>,
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
||||
},
|
||||
});
|
||||
|
||||
const resetOutboundsTraffic = useCallback(async (tag: string) => {
|
||||
const msg = await HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag });
|
||||
if (msg?.success) await fetchOutboundsTraffic();
|
||||
}, [fetchOutboundsTraffic]);
|
||||
const restartMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/server/restartXrayService') as ApiMsg;
|
||||
if (!msg?.success) return msg;
|
||||
await PromiseUtil.sleep(500);
|
||||
const r = await HttpUtil.get('/panel/xray/getXrayResult') as ApiMsg<string>;
|
||||
if (r?.success) setRestartResult(r.obj || '');
|
||||
return msg;
|
||||
},
|
||||
});
|
||||
|
||||
const applyOutboundsEvent = useCallback((payload: unknown) => {
|
||||
if (Array.isArray(payload)) setOutboundsTraffic(payload as OutboundTrafficRow[]);
|
||||
}, []);
|
||||
const resetDefaultMut = useMutation({
|
||||
mutationFn: async () => HttpUtil.get('/panel/setting/getDefaultJsonConfig') as Promise<ApiMsg<XraySettingsValue>>,
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success && msg.obj) {
|
||||
const cloned = JSON.parse(JSON.stringify(msg.obj));
|
||||
setTemplateSettings(cloned);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const saveAll = useCallback(async () => { await saveMut.mutateAsync(); }, [saveMut]);
|
||||
const resetOutboundsTraffic = useCallback(async (tag: string) => { await resetTrafficMut.mutateAsync(tag); }, [resetTrafficMut]);
|
||||
const restartXray = useCallback(async () => { await restartMut.mutateAsync(); }, [restartMut]);
|
||||
const resetToDefault = useCallback(async () => { await resetDefaultMut.mutateAsync(); }, [resetDefaultMut]);
|
||||
|
||||
const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending;
|
||||
|
||||
const testOutbound = useCallback(
|
||||
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
|
||||
@@ -212,11 +273,11 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
outbound: JSON.stringify(outbound),
|
||||
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
|
||||
mode,
|
||||
});
|
||||
if (msg?.success) {
|
||||
}) as ApiMsg<OutboundTestResult>;
|
||||
if (msg?.success && msg.obj) {
|
||||
setOutboundTestStates((prev) => ({
|
||||
...prev,
|
||||
[index]: { testing: false, result: msg.obj },
|
||||
[index]: { testing: false, result: msg.obj as OutboundTestResult },
|
||||
}));
|
||||
return msg.obj;
|
||||
}
|
||||
@@ -273,43 +334,16 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
}
|
||||
}, [testingAll, testOutbound]);
|
||||
|
||||
const resetToDefault = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
|
||||
if (msg?.success) {
|
||||
const cloned = JSON.parse(JSON.stringify(msg.obj));
|
||||
setTemplateSettings(cloned);
|
||||
}
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, [setTemplateSettings]);
|
||||
|
||||
const restartXray = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
|
||||
if (msg?.success) {
|
||||
await PromiseUtil.sleep(500);
|
||||
const r = await HttpUtil.get('/panel/xray/getXrayResult');
|
||||
if (r?.success) setRestartResult(r.obj || '');
|
||||
}
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
fetchOutboundsTraffic();
|
||||
const timer = window.setInterval(() => {
|
||||
const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
|
||||
const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
|
||||
setSaveDisabled(!(dirtyXray || dirtyUrl));
|
||||
}, DIRTY_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [fetchAll, fetchOutboundsTraffic]);
|
||||
}, []);
|
||||
|
||||
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
@@ -330,9 +364,8 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
outboundTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
fetchOutboundsTraffic,
|
||||
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
|
||||
resetOutboundsTraffic,
|
||||
applyOutboundsEvent,
|
||||
testOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
@@ -357,9 +390,8 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
outboundTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
fetchOutboundsTraffic,
|
||||
fetchOutboundsTrafficCb,
|
||||
resetOutboundsTraffic,
|
||||
applyOutboundsEvent,
|
||||
testOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
|
||||
Reference in New Issue
Block a user