Files
3x-ui/frontend/scripts/build-openapi.mjs
Sanaei cfe1b25ca0 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.
2026-05-24 21:34:52 +02:00

219 lines
5.9 KiB
JavaScript

#!/usr/bin/env node
import { writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { sections } from '../src/pages/api-docs/endpoints.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const outPath = join(__dirname, '..', 'public', 'openapi.json');
const PANEL_VERSION = process.env.X_UI_VERSION || '3.x';
const SECURITY_SCHEMES = {
bearerAuth: {
type: 'http',
scheme: 'bearer',
description: 'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
},
cookieAuth: {
type: 'apiKey',
in: 'cookie',
name: '3x-ui',
description: 'Session cookie set by POST /login. Browser-only.',
},
};
function ginPathToOpenApi(path) {
return path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');
}
function extractPathParams(openApiPath) {
const params = [];
const re = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
let m;
while ((m = re.exec(openApiPath)) !== null) params.push(m[1]);
return params;
}
function mapType(t) {
const v = String(t || '').toLowerCase();
if (v === 'number' || v === 'integer' || v === 'int') return 'integer';
if (v === 'float' || v === 'double') return 'number';
if (v === 'boolean' || v === 'bool') return 'boolean';
if (v === 'array') return 'array';
if (v === 'object') return 'object';
return 'string';
}
function tryParseJson(raw) {
if (typeof raw !== 'string') return undefined;
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
function paramToOpenApi(p) {
const out = {
name: p.name,
in: p.in,
required: p.in === 'path' ? true : !p.optional,
description: p.desc || '',
schema: { type: mapType(p.type) },
};
if (p.defaultValue !== undefined) out.schema.default = p.defaultValue;
return out;
}
function buildOperation(ep, tag) {
const op = {
tags: [tag],
summary: ep.summary || '',
operationId: `${ep.method.toLowerCase()}_${ep.path.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, '')}`,
};
if (ep.description) op.description = ep.description;
if (ep.deprecated) op.deprecated = true;
const params = [];
const bodyParams = [];
for (const p of ep.params || []) {
if (p.in === 'body') {
bodyParams.push(p);
} else if (p.in === 'path' || p.in === 'query' || p.in === 'header') {
params.push(paramToOpenApi(p));
}
}
const openApiPath = ginPathToOpenApi(ep.path);
const declared = new Set(params.filter((x) => x.in === 'path').map((x) => x.name));
for (const name of extractPathParams(openApiPath)) {
if (declared.has(name)) continue;
params.push({
name,
in: 'path',
required: true,
description: '',
schema: { type: 'string' },
});
}
if (params.length > 0) op.parameters = params;
if (ep.body || bodyParams.length > 0) {
const example = tryParseJson(ep.body);
const properties = {};
const required = [];
for (const bp of bodyParams) {
properties[bp.name] = {
type: mapType(bp.type),
description: bp.desc || '',
};
if (!bp.optional) required.push(bp.name);
}
const schema = bodyParams.length > 0
? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
: { type: 'object' };
op.requestBody = {
required: required.length > 0 || bodyParams.length === 0,
content: {
'application/json': {
schema,
...(example !== undefined ? { example } : {}),
},
},
};
}
const responses = {};
const successExample = tryParseJson(ep.response);
responses['200'] = {
description: 'Successful response',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
msg: { type: 'string' },
obj: {},
},
},
...(successExample !== undefined ? { example: successExample } : {}),
},
},
};
const errExample = tryParseJson(ep.errorResponse);
if (errExample !== undefined || ep.errorStatus) {
const code = String(ep.errorStatus || 400);
responses[code] = {
description: 'Error response',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
msg: { type: 'string' },
},
},
...(errExample !== undefined ? { example: errExample } : {}),
},
},
};
}
op.responses = responses;
return op;
}
function buildSpec() {
const paths = {};
for (const section of sections) {
const tag = section.title;
for (const ep of section.endpoints) {
const openApiPath = ginPathToOpenApi(ep.path);
if (!paths[openApiPath]) paths[openApiPath] = {};
paths[openApiPath][ep.method.toLowerCase()] = buildOperation(ep, tag);
}
}
const tags = sections.map((s) => ({
name: s.title,
description: s.description || '',
}));
return {
openapi: '3.0.3',
info: {
title: '3X-UI Panel API',
version: PANEL_VERSION,
description:
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes.',
},
servers: [
{ url: '/', description: 'Current panel (basePath aware)' },
],
components: {
securitySchemes: SECURITY_SCHEMES,
},
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
tags,
paths,
};
}
const spec = buildSpec();
writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
const pathCount = Object.keys(spec.paths).length;
let opCount = 0;
for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
console.log(`[openapi] wrote ${outPath}`);
console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
void pathToFileURL;