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:
Sanaei
2026-05-24 21:34:52 +02:00
committed by GitHub
parent 867a145979
commit cfe1b25ca0
60 changed files with 8724 additions and 2122 deletions
+127 -95
View File
@@ -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,