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

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,67 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { AllSetting } from '@/models/setting';
import { keys } from '@/api/queryKeys';
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
msg?: string;
}
async function fetchAllSetting(): Promise<unknown> {
const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true }) as ApiMsg;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
return msg.obj;
}
export function useAllSettings() {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<AllSetting>(() => new AllSetting());
const [extraSpinning, setExtraSpinning] = useState(false);
const query = useQuery({
queryKey: keys.settings.all(),
queryFn: fetchAllSetting,
staleTime: Infinity,
});
const server = useMemo(() => new AllSetting(query.data), [query.data]);
useEffect(() => {
if (query.data !== undefined) {
setDraft(new AllSetting(query.data));
}
}, [query.data]);
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
setDraft((prev) => {
const next = new AllSetting(prev);
Object.assign(next, patch);
return next;
});
}, []);
const saveMut = useMutation({
mutationFn: async (next: AllSetting) =>
HttpUtil.post('/panel/setting/update', next) as Promise<ApiMsg>,
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
},
});
const saveAll = useCallback(() => saveMut.mutateAsync(draft), [saveMut, draft]);
const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
return {
allSetting: draft,
updateSetting,
fetched: query.data !== undefined,
spinning: extraSpinning || saveMut.isPending,
setSpinning: setExtraSpinning,
saveDisabled,
saveAll,
};
}

View File

@@ -0,0 +1,63 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { keys } from '@/api/queryKeys';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
obj?: T;
}
export interface ProbeResult {
status: string;
latencyMs?: number;
xrayVersion?: string;
error?: string;
}
export function useNodeMutations() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() });
const createMut = useMutation({
mutationFn: (payload: Partial<NodeRecord>) =>
HttpUtil.post('/panel/api/nodes/add', payload) as Promise<ApiMsg>,
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: Partial<NodeRecord> }) =>
HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as Promise<ApiMsg>,
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const removeMut = useMutation({
mutationFn: (id: number) =>
HttpUtil.post(`/panel/api/nodes/del/${id}`) as Promise<ApiMsg>,
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const setEnableMut = useMutation({
mutationFn: ({ id, enable }: { id: number; enable: boolean }) =>
HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }) as Promise<ApiMsg>,
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const probeMut = useMutation({
mutationFn: (id: number) =>
HttpUtil.post(`/panel/api/nodes/probe/${id}`) as Promise<ApiMsg<ProbeResult>>,
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
return {
create: (payload: Partial<NodeRecord>) => createMut.mutateAsync(payload),
update: (id: number, payload: Partial<NodeRecord>) => updateMut.mutateAsync({ id, payload }),
remove: (id: number) => removeMut.mutateAsync(id),
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
probe: (id: number) => probeMut.mutateAsync(id),
testConnection: (payload: Partial<NodeRecord>) =>
HttpUtil.post('/panel/api/nodes/test', payload) as Promise<ApiMsg<ProbeResult>>,
};
}

View File

@@ -0,0 +1,108 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { HttpUtil } from '@/utils';
import { keys } from '@/api/queryKeys';
export interface NodeRecord {
id: number;
name?: string;
remark?: string;
scheme?: string;
address?: string;
port?: number;
basePath?: string;
apiToken?: string;
enable?: boolean;
status?: 'online' | 'offline' | string;
latencyMs?: number;
cpuPct?: number;
memPct?: number;
xrayVersion?: string;
panelVersion?: string;
uptimeSecs?: number;
inboundCount?: number;
clientCount?: number;
onlineCount?: number;
depletedCount?: number;
lastHeartbeat?: number;
lastError?: string;
allowPrivateAddress?: boolean;
[key: string]: unknown;
}
export interface NodeTotals {
total: number;
online: number;
offline: number;
avgLatency: number;
inbounds: number;
clients: number;
onlineClients: number;
depleted: number;
}
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
obj?: T;
}
async function fetchNodes(): Promise<NodeRecord[]> {
const msg = await HttpUtil.get('/panel/api/nodes/list', undefined, { silent: true }) as ApiMsg<NodeRecord[]>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch nodes');
return Array.isArray(msg.obj) ? msg.obj : [];
}
export function useNodesQuery() {
const query = useQuery({
queryKey: keys.nodes.list(),
queryFn: fetchNodes,
});
const nodes = useMemo(() => query.data ?? [], [query.data]);
const totals = useMemo<NodeTotals>(() => {
let online = 0;
let offline = 0;
let latencySum = 0;
let latencyCount = 0;
let inbounds = 0;
let clients = 0;
let onlineClients = 0;
let depleted = 0;
for (const n of nodes) {
inbounds += n.inboundCount || 0;
clients += n.clientCount || 0;
onlineClients += n.onlineCount || 0;
depleted += n.depletedCount || 0;
if (!n.enable) continue;
if (n.status === 'online') {
online += 1;
if (n.latencyMs && n.latencyMs > 0) {
latencySum += n.latencyMs;
latencyCount += 1;
}
} else if (n.status === 'offline') {
offline += 1;
}
}
return {
total: nodes.length,
online,
offline,
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
inbounds,
clients,
onlineClients,
depleted,
};
}, [nodes]);
return {
nodes,
totals,
loading: query.isFetching,
fetched: query.data !== undefined,
};
}

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,30 @@
export const keys = {
server: {
status: () => ['server', 'status'] as const,
},
nodes: {
root: () => ['nodes'] as const,
list: () => ['nodes', 'list'] as const,
},
settings: {
root: () => ['settings'] as const,
all: () => ['settings', 'all'] as const,
defaults: () => ['settings', 'defaults'] as const,
},
inbounds: {
root: () => ['inbounds'] as const,
slim: () => ['inbounds', 'slim'] as const,
options: () => ['inbounds', 'options'] as const,
},
clients: {
root: () => ['clients'] as const,
list: (params: unknown) => ['clients', 'list', params] as const,
onlines: () => ['clients', 'onlines'] as const,
lastOnline: () => ['clients', 'lastOnline'] as const,
},
xray: {
root: () => ['xray'] as const,
config: () => ['xray', 'config'] as const,
outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const,
},
} as const;

View File

@@ -0,0 +1,77 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { WebSocketClient } from '@/api/websocket.js';
import { keys } from '@/api/queryKeys';
type Handler = (payload: unknown) => void;
interface SharedClient {
connect(): void;
on(event: string, fn: Handler): void;
off(event: string, fn: Handler): void;
}
let sharedClient: SharedClient | null = null;
function getSharedClient(): SharedClient {
if (sharedClient) return sharedClient;
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
sharedClient = new WebSocketClient(basePath) as SharedClient;
return sharedClient;
}
let invalidateTimer: number | null = null;
export function useWebSocketBridge() {
const queryClient = useQueryClient();
useEffect(() => {
const client = getSharedClient();
const onInvalidate: Handler = (payload) => {
const p = payload as { type?: string } | undefined;
if (!p || (p.type !== 'inbounds' && p.type !== 'clients')) return;
if (invalidateTimer != null) clearTimeout(invalidateTimer);
invalidateTimer = window.setTimeout(() => {
invalidateTimer = null;
if (p.type === 'inbounds') {
queryClient.invalidateQueries({ queryKey: ['inbounds'] });
} else {
queryClient.invalidateQueries({ queryKey: ['clients'] });
}
}, 200);
};
const onOutbounds: Handler = (payload) => {
queryClient.setQueryData(keys.xray.outboundsTraffic(), payload);
};
const onNodes: Handler = (payload) => {
if (!Array.isArray(payload)) return;
queryClient.setQueryData(keys.nodes.list(), payload);
};
const onInbounds: Handler = (payload) => {
if (!Array.isArray(payload)) return;
queryClient.setQueryData(keys.inbounds.slim(), payload);
};
client.on('invalidate', onInvalidate);
client.on('outbounds', onOutbounds);
client.on('nodes', onNodes);
client.on('inbounds', onInbounds);
client.connect();
return () => {
client.off('invalidate', onInvalidate);
client.off('outbounds', onOutbounds);
client.off('nodes', onNodes);
client.off('inbounds', onInbounds);
if (invalidateTimer != null) {
clearTimeout(invalidateTimer);
invalidateTimer = null;
}
};
}, [queryClient]);
}