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

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>API Docs</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/api-docs.tsx"></script>
</body>
</html>

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clients</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/clients.tsx"></script>
</body>
</html>

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Inbounds</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/inbounds.tsx"></script>
</body>
</html>

View File

@@ -3,11 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Overview</title>
<title>3X-UI</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/index.tsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nodes</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/nodes.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -10,15 +10,18 @@
},
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "npm run gen:api && vite build",
"preview": "vite preview",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"gen:api": "node scripts/build-openapi.mjs"
},
"dependencies": {
"@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,12 +32,15 @@
"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",
"swagger-ui-react": "^5.32.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",

4944
frontend/public/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,218 @@
#!/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;

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Settings</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/settings.tsx"></script>
</body>
</html>

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]);
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from 'react';
import type { ComponentType } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Drawer, Layout, Menu } from 'antd';
import type { MenuProps } from 'antd';
@@ -23,11 +24,7 @@ import './AppSidebar.css';
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const DONATE_URL = 'https://donate.sanaei.dev/';
interface AppSidebarProps {
basePath?: string;
requestUri?: string;
}
const LOGOUT_KEY = '__logout__';
type IconName = 'dashboard' | 'user' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
@@ -100,31 +97,34 @@ function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
);
}
export default function AppSidebar({ basePath = '', requestUri = '' }: AppSidebarProps) {
export default function AppSidebar() {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
const navigate = useNavigate();
const { pathname } = useLocation();
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
const [drawerOpen, setDrawerOpen] = useState(false);
const prefix = basePath.startsWith('/') ? basePath : `/${basePath || ''}`;
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
const panelVersion = window.X_UI_CUR_VER || '';
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
{ key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
{ key: 'logout', icon: 'logout', title: t('logout') },
], [prefix, t]);
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
{ key: '/inbounds', icon: 'user', title: t('menu.inbounds') },
{ key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
], [t]);
const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
const selectedKey = pathname === '' ? '/' : pathname;
const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
items.map((tab) => {
const Icon = iconByName[tab.icon];
@@ -137,17 +137,13 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
[]);
const openLink = useCallback(async (key: string) => {
if (key === 'logout') {
if (key === LOGOUT_KEY) {
await HttpUtil.post('/logout');
window.location.href = basePath || '/';
window.location.href = window.X_UI_BASE_PATH || '/';
return;
}
if (key.startsWith('http')) {
window.open(key);
} else {
window.location.href = key;
}
}, [basePath]);
navigate(key);
}, [navigate]);
const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(({ key }) => {
openLink(String(key));
@@ -205,7 +201,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[requestUri]}
selectedKeys={[selectedKey]}
className="sider-nav"
items={toMenuItems(navItems)}
onClick={onMenuClick}
@@ -213,7 +209,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[requestUri]}
selectedKeys={[selectedKey]}
className="sider-utility"
items={toMenuItems(utilItems)}
onClick={onMenuClick}
@@ -260,7 +256,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[requestUri]}
selectedKeys={[selectedKey]}
className="drawer-menu drawer-nav"
items={toMenuItems(navItems)}
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
@@ -268,7 +264,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[requestUri]}
selectedKeys={[selectedKey]}
className="drawer-menu drawer-utility"
items={toMenuItems(utilItems)}
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}

View File

@@ -1,28 +0,0 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import ClientsPage from '@/pages/clients/ClientsPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<ClientsPage />
</ThemeProvider>,
);
}
});

View File

@@ -1,28 +0,0 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import InboundsPage from '@/pages/inbounds/InboundsPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<InboundsPage />
</ThemeProvider>,
);
}
});

View File

@@ -1,28 +0,0 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import IndexPage from '@/pages/index/IndexPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<IndexPage />
</ThemeProvider>,
);
}
});

View File

@@ -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>,
);
}

View File

@@ -1,28 +0,0 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import NodesPage from '@/pages/nodes/NodesPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<NodesPage />
</ThemeProvider>,
);
}
});

View File

@@ -1,28 +0,0 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import SettingsPage from '@/pages/settings/SettingsPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<SettingsPage />
</ThemeProvider>,
);
}
});

View File

@@ -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>,
);
}

View File

@@ -1,28 +0,0 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import XrayPage from '@/pages/xray/XrayPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<XrayPage />
</ThemeProvider>,
);
}
});

View File

@@ -1,69 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { HttpUtil } from '@/utils';
import { AllSetting } from '@/models/setting';
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
}
export function useAllSetting() {
const [allSetting, setAllSetting] = useState<AllSetting>(() => new AllSetting());
const [oldAllSetting, setOldAllSetting] = useState<AllSetting>(() => new AllSetting());
const [fetched, setFetched] = useState(false);
const [spinning, setSpinning] = useState(false);
const fetchedRef = useRef(false);
const applyServerState = useCallback((obj: unknown) => {
setAllSetting(new AllSetting(obj));
setOldAllSetting(new AllSetting(obj));
}, []);
const fetchAll = useCallback(async () => {
const msg = await HttpUtil.post('/panel/setting/all') as ApiMsg;
if (msg?.success) {
applyServerState(msg.obj);
fetchedRef.current = true;
setFetched(true);
}
}, [applyServerState]);
const saveAll = useCallback(async () => {
setSpinning(true);
try {
const msg = await HttpUtil.post('/panel/setting/update', allSetting) as ApiMsg;
if (msg?.success) await fetchAll();
} finally {
setSpinning(false);
}
}, [allSetting, fetchAll]);
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
setAllSetting((prev) => {
const next = new AllSetting(prev);
Object.assign(next, patch);
return next;
});
}, []);
const saveDisabled = useMemo(
() => allSetting.equals(oldAllSetting),
[allSetting, oldAllSetting],
);
useEffect(() => {
fetchAll();
}, [fetchAll]);
return {
allSetting,
updateSetting,
fetched,
spinning,
setSpinning,
saveDisabled,
fetchAll,
saveAll,
};
}

View File

@@ -1,5 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { keys } from '@/api/queryKeys';
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
@@ -84,22 +87,49 @@ interface ClientPageResponse {
}
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
const DEFAULT_SUMMARY: ClientsSummary = {
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
};
function buildQS(p: ClientQueryParams): string {
const sp = new URLSearchParams();
sp.set('page', String(p.page || 1));
sp.set('pageSize', String(p.pageSize || DEFAULT_QUERY.pageSize));
if (p.search) sp.set('search', p.search);
if (p.filter) sp.set('filter', p.filter);
if (p.protocol) sp.set('protocol', p.protocol);
if (p.inbound && p.inbound > 0) sp.set('inbound', String(p.inbound));
if (p.sort) sp.set('sort', p.sort);
if (p.order) sp.set('order', p.order);
return sp.toString();
}
async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageResponse> {
const qs = buildQS(params);
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true }) as ApiMsg<ClientPageResponse>;
if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
return msg.obj;
}
async function fetchInboundOptions(): Promise<InboundOption[]> {
const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true }) as ApiMsg<InboundOption[]>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbound options');
return Array.isArray(msg.obj) ? msg.obj : [];
}
async function fetchDefaults(): Promise<Record<string, unknown>> {
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg<Record<string, unknown>>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
return msg.obj || {};
}
export function useClients() {
const [clients, setClients] = useState<ClientRecord[]>([]);
const [total, setTotal] = useState(0);
const [filtered, setFiltered] = useState(0);
const [summary, setSummary] = useState<ClientsSummary>({
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
});
const [inbounds, setInbounds] = useState<InboundOption[]>([]);
const [onlines, setOnlines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false);
const queryClient = useQueryClient();
const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
// Shallow-compare against the previous query so callers can pass a fresh
// object on every render (the common React pattern) without triggering a
// re-fetch when nothing actually changed.
// setQuery shallow-compares so callers can pass a fresh object every render
// (the common React pattern) without triggering a re-fetch when nothing
// actually changed.
const setQuery = useCallback((next: ClientQueryParams) => {
setQueryState((prev) => {
if (
@@ -115,86 +145,69 @@ export function useClients() {
return next;
});
}, []);
const [subSettings, setSubSettings] = useState<SubSettings>({
enable: false, subURI: '', subJsonURI: '', subJsonEnable: false,
const listQuery = useQuery({
queryKey: keys.clients.list(query),
queryFn: () => fetchClientPage(query),
staleTime: Infinity,
placeholderData: keepPreviousData,
});
const [ipLimitEnable, setIpLimitEnable] = useState(false);
const [tgBotEnable, setTgBotEnable] = useState(false);
const [expireDiff, setExpireDiff] = useState(0);
const [trafficDiff, setTrafficDiff] = useState(0);
const [pageSize, setPageSize] = useState(0);
const clientsRef = useRef<ClientRecord[]>([]);
const queryRef = useRef<ClientQueryParams>(query);
const invalidateTimerRef = useRef<number | null>(null);
const inboundOptionsQuery = useQuery({
queryKey: keys.inbounds.options(),
queryFn: fetchInboundOptions,
staleTime: Infinity,
});
useEffect(() => { clientsRef.current = clients; }, [clients]);
useEffect(() => { queryRef.current = query; }, [query]);
const defaultsQuery = useQuery({
queryKey: keys.settings.defaults(),
queryFn: fetchDefaults,
staleTime: Infinity,
});
const buildQS = (p: ClientQueryParams) => {
const sp = new URLSearchParams();
sp.set('page', String(p.page || 1));
sp.set('pageSize', String(p.pageSize || DEFAULT_QUERY.pageSize));
if (p.search) sp.set('search', p.search);
if (p.filter) sp.set('filter', p.filter);
if (p.protocol) sp.set('protocol', p.protocol);
if (p.inbound && p.inbound > 0) sp.set('inbound', String(p.inbound));
if (p.sort) sp.set('sort', p.sort);
if (p.order) sp.set('order', p.order);
return sp.toString();
};
const onlinesQuery = useQuery({
queryKey: keys.clients.onlines(),
queryFn: async () => {
const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg<string[]>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
return Array.isArray(msg.obj) ? msg.obj : [];
},
staleTime: Infinity,
});
const refresh = useCallback(async (override?: ClientQueryParams) => {
setLoading(true);
try {
const params = override ?? queryRef.current;
const qs = buildQS(params);
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`) as ApiMsg<ClientPageResponse>;
if (msg?.success && msg.obj) {
setClients(Array.isArray(msg.obj.items) ? msg.obj.items : []);
setTotal(msg.obj.total ?? 0);
setFiltered(msg.obj.filtered ?? 0);
if (msg.obj.summary) setSummary(msg.obj.summary);
}
setFetched(true);
} finally {
setLoading(false);
}
}, []);
const clients = listQuery.data?.items ?? [];
const total = listQuery.data?.total ?? 0;
const filtered = listQuery.data?.filtered ?? 0;
const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
const fetched = listQuery.data !== undefined;
const loading = listQuery.isFetching;
// Inbound options are picker-shaped and don't depend on the clients query —
// fetch them once on mount instead of every refresh.
useEffect(() => {
let cancelled = false;
(async () => {
const msg = await HttpUtil.get('/panel/api/inbounds/options') as ApiMsg<InboundOption[]>;
if (cancelled) return;
if (msg?.success) setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
})();
return () => { cancelled = true; };
}, []);
const inbounds = inboundOptionsQuery.data ?? [];
const onlines = onlinesQuery.data ?? [];
const fetchSubSettings = useCallback(async () => {
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg<Record<string, unknown>>;
if (!msg?.success) return;
const s = msg.obj || {};
setSubSettings({
enable: !!s.subEnable,
subURI: (s.subURI as string) || '',
subJsonURI: (s.subJsonURI as string) || '',
subJsonEnable: !!s.subJsonEnable,
});
setIpLimitEnable(!!s.ipLimitEnable);
setTgBotEnable(!!s.tgBotEnable);
setExpireDiff(((s.expireDiff as number) ?? 0) * 86400000);
setTrafficDiff(((s.trafficDiff as number) ?? 0) * 1073741824);
setPageSize((s.pageSize as number) ?? 0);
}, []);
const defaults = defaultsQuery.data ?? {};
const subSettings: SubSettings = useMemo(() => ({
enable: !!defaults.subEnable,
subURI: (defaults.subURI as string) || '',
subJsonURI: (defaults.subJsonURI as string) || '',
subJsonEnable: !!defaults.subJsonEnable,
}), [defaults.subEnable, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable]);
const ipLimitEnable = !!defaults.ipLimitEnable;
const tgBotEnable = !!defaults.tgBotEnable;
const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
const pageSize = (defaults.pageSize as number) ?? 0;
const invalidateAll = useCallback(
() => queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
[queryClient],
);
const refresh = useCallback(async () => {
await invalidateAll();
}, [invalidateAll]);
// hydrate fetches the full client record (uuid, password, flow, ...) for a
// single email. The paged list endpoint omits these to keep the row payload
// tiny; edit / info / qr / link modals call this to get a complete record
// before opening.
const hydrate = useCallback(async (email: string): Promise<{ client: ClientRecord; inboundIds: number[] } | null> => {
if (!email) return null;
const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`) as ApiMsg<{ client: ClientRecord; inboundIds: number[] }>;
@@ -202,88 +215,109 @@ export function useClients() {
return msg.obj;
}, []);
const create = useCallback(async (payload: unknown) => {
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const createMut = useMutation({
mutationFn: (payload: unknown) =>
HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as Promise<ApiMsg>,
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const update = useCallback(async (email: string, client: unknown) => {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const updateMut = useMutation({
mutationFn: ({ email, client }: { email: string; client: unknown }) =>
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS) as Promise<ApiMsg>,
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const remove = useCallback(async (email: string, keepTraffic = false) => {
if (!email) return null;
const encoded = encodeURIComponent(email);
const url = keepTraffic
? `/panel/api/clients/del/${encoded}?keepTraffic=1`
: `/panel/api/clients/del/${encoded}`;
const msg = await HttpUtil.post(url) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const removeMut = useMutation({
mutationFn: ({ email, keepTraffic }: { email: string; keepTraffic?: boolean }) => {
const url = keepTraffic
? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
: `/panel/api/clients/del/${encodeURIComponent(email)}`;
return HttpUtil.post(url) as Promise<ApiMsg>;
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const removeMany = useCallback(async (emails: string[], keepTraffic = false) => {
if (!Array.isArray(emails) || emails.length === 0) return [];
const suffix = keepTraffic ? '?keepTraffic=1' : '';
const results = await Promise.all(emails.map((email) => {
const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
return HttpUtil.post(url, undefined, { silent: true }) as Promise<ApiMsg>;
}));
await refresh();
return results;
}, [refresh]);
const removeManyMut = useMutation({
mutationFn: async ({ emails, keepTraffic }: { emails: string[]; keepTraffic?: boolean }) => {
const suffix = keepTraffic ? '?keepTraffic=1' : '';
const results = await Promise.all(emails.map((email) => {
const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
return HttpUtil.post(url, undefined, { silent: true }) as Promise<ApiMsg>;
}));
return results;
},
onSuccess: () => invalidateAll(),
});
const bulkAdjust = useCallback(async (emails: string[], addDays: number, addBytes: number) => {
if (!Array.isArray(emails) || emails.length === 0) return null;
const msg = await HttpUtil.post(
'/panel/api/clients/bulkAdjust',
{ emails, addDays, addBytes },
JSON_HEADERS,
) as ApiMsg<{ adjusted: number; skipped?: { email: string; reason: string }[] }>;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const bulkAdjustMut = useMutation({
mutationFn: (payload: { emails: string[]; addDays: number; addBytes: number }) =>
HttpUtil.post(
'/panel/api/clients/bulkAdjust',
payload,
JSON_HEADERS,
) as Promise<ApiMsg<{ adjusted: number; skipped?: { email: string; reason: string }[] }>>,
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const attach = useCallback(async (email: string, inboundIds: number[]) => {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const attachMut = useMutation({
mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, JSON_HEADERS) as Promise<ApiMsg>,
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const detach = useCallback(async (email: string, inboundIds: number[]) => {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const detachMut = useMutation({
mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS) as Promise<ApiMsg>,
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const resetTraffic = useCallback(async (client: ClientRecord) => {
if (!client?.email) return null;
const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`;
const msg = await HttpUtil.post(url) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const resetTrafficMut = useMutation({
mutationFn: (email: string) =>
HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`) as Promise<ApiMsg>,
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const resetAllTraffics = useCallback(async () => {
const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics') as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const resetAllTrafficsMut = useMutation({
mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics') as Promise<ApiMsg>,
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const delDepleted = useCallback(async () => {
const msg = await HttpUtil.post('/panel/api/clients/delDepleted') as ApiMsg<{ deleted?: number }>;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const delDepletedMut = useMutation({
mutationFn: () => HttpUtil.post('/panel/api/clients/delDepleted') as Promise<ApiMsg<{ deleted?: number }>>,
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
const update = useCallback((email: string, client: unknown) => {
if (!email) return Promise.resolve(null as unknown as ApiMsg);
return updateMut.mutateAsync({ email, client });
}, [updateMut]);
const remove = useCallback((email: string, keepTraffic = false) => {
if (!email) return Promise.resolve(null as unknown as ApiMsg);
return removeMut.mutateAsync({ email, keepTraffic });
}, [removeMut]);
const removeMany = useCallback((emails: string[], keepTraffic = false) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve([] as ApiMsg[]);
return removeManyMut.mutateAsync({ emails, keepTraffic });
}, [removeManyMut]);
const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
}, [bulkAdjustMut]);
const attach = useCallback((email: string, inboundIds: number[]) => {
if (!email) return Promise.resolve(null as unknown as ApiMsg);
return attachMut.mutateAsync({ email, inboundIds });
}, [attachMut]);
const detach = useCallback((email: string, inboundIds: number[]) => {
if (!email) return Promise.resolve(null as unknown as ApiMsg);
return detachMut.mutateAsync({ email, inboundIds });
}, [detachMut]);
const resetTraffic = useCallback((client: ClientRecord) => {
if (!client?.email) return Promise.resolve(null as unknown as ApiMsg);
return resetTrafficMut.mutateAsync(client.email);
}, [resetTrafficMut]);
const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);
const delDepleted = useCallback(() => delDepletedMut.mutateAsync(), [delDepletedMut]);
const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
if (!client?.email) return null;
@@ -302,57 +336,53 @@ export function useClients() {
return update(client.email, payload);
}, [update]);
// WS-driven in-place merges. Page wires these via useWebSocket; the bridge
// covers coarse 'invalidate' and 'inbounds' events centrally.
const queryRef = useRef(query);
queryRef.current = query;
const applyTrafficEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { onlineClients?: string[] };
if (Array.isArray(p.onlineClients)) {
setOnlines(p.onlineClients);
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
}
}, []);
}, [queryClient]);
const applyClientStatsEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { clients?: ClientTraffic[] & { email?: string }[] };
const p = payload as { clients?: (ClientTraffic & { email?: string })[] };
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
const byEmail = new Map<string, ClientTraffic>();
for (const row of p.clients as (ClientTraffic & { email?: string })[]) {
for (const row of p.clients) {
if (row && row.email) byEmail.set(row.email, row);
}
const cur = clientsRef.current || [];
let touched = false;
const next = cur.slice();
for (let i = 0; i < next.length; i++) {
const row = next[i];
const upd = byEmail.get(row?.email);
if (!upd) continue;
const merged: ClientTraffic = { ...(row.traffic || {}) };
if (typeof upd.up === 'number') merged.up = upd.up;
if (typeof upd.down === 'number') merged.down = upd.down;
if (typeof upd.total === 'number') merged.total = upd.total;
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
next[i] = { ...row, traffic: merged };
touched = true;
}
if (touched) setClients(next);
}, []);
const applyInvalidate = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { type?: string };
if (p.type !== 'inbounds' && p.type !== 'clients') return;
if (invalidateTimerRef.current != null) clearTimeout(invalidateTimerRef.current);
invalidateTimerRef.current = window.setTimeout(() => {
invalidateTimerRef.current = null;
refresh();
}, 200);
}, [refresh]);
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(queryRef.current), (prev) => {
if (!prev) return prev;
let touched = false;
const next = prev.items.slice();
for (let i = 0; i < next.length; i++) {
const row = next[i];
const upd = byEmail.get(row?.email);
if (!upd) continue;
const merged: ClientTraffic = { ...(row.traffic || {}) };
if (typeof upd.up === 'number') merged.up = upd.up;
if (typeof upd.down === 'number') merged.down = upd.down;
if (typeof upd.total === 'number') merged.total = upd.total;
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
next[i] = { ...row, traffic: merged };
touched = true;
}
if (!touched) return prev;
return { ...prev, items: next };
});
}, [queryClient]);
useEffect(() => {
Promise.all([refresh(query), fetchSubSettings()]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, fetchSubSettings]);
queryRef.current = query;
}, [query]);
return {
clients,
@@ -386,6 +416,5 @@ export function useClients() {
setEnable,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
};
}

View File

@@ -1,177 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { HttpUtil } from '@/utils';
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;
}
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
obj?: T;
}
interface NodeTotals {
total: number;
online: number;
offline: number;
avgLatency: number;
inbounds: number;
clients: number;
onlineClients: number;
depleted: number;
}
export function useNodes() {
const [nodes, setNodes] = useState<NodeRecord[]>([]);
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false);
const fetchedRef = useRef(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const msg = await HttpUtil.get('/panel/api/nodes/list') as ApiMsg<NodeRecord[]>;
if (msg?.success) {
setNodes(Array.isArray(msg.obj) ? msg.obj : []);
}
fetchedRef.current = true;
setFetched(true);
} finally {
setLoading(false);
}
}, []);
const applyNodesEvent = useCallback((payload: unknown) => {
if (Array.isArray(payload)) {
setNodes(payload as NodeRecord[]);
if (!fetchedRef.current) {
fetchedRef.current = true;
setFetched(true);
}
}
}, []);
const create = useCallback(async (payload: Partial<NodeRecord>) => {
const msg = await HttpUtil.post('/panel/api/nodes/add', payload) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const update = useCallback(async (id: number, payload: Partial<NodeRecord>) => {
const msg = await HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const remove = useCallback(async (id: number) => {
const msg = await HttpUtil.post(`/panel/api/nodes/del/${id}`) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const setEnable = useCallback(async (id: number, enable: boolean) => {
const msg = await HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const testConnection = useCallback(async (payload: Partial<NodeRecord>) => {
return await HttpUtil.post('/panel/api/nodes/test', payload) as ApiMsg<{
status: string;
latencyMs?: number;
xrayVersion?: string;
error?: string;
}>;
}, []);
const probe = useCallback(async (id: number) => {
const msg = await HttpUtil.post(`/panel/api/nodes/probe/${id}`) as ApiMsg<{
status: string;
latencyMs?: number;
error?: string;
}>;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
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]);
useEffect(() => {
refresh();
}, [refresh]);
return {
nodes,
loading,
fetched,
totals,
refresh,
applyNodesEvent,
create,
update,
remove,
setEnable,
testConnection,
probe,
};
}

View File

@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
const TITLES: Record<string, string> = {
'/': 'Overview',
'/inbounds': 'Inbounds',
'/clients': 'Clients',
'/nodes': 'Nodes',
'/settings': 'Settings',
'/xray': 'Xray Config',
'/api-docs': 'API Docs',
};
export function usePageTitle() {
const { pathname } = useLocation();
useEffect(() => {
const title = TITLES[pathname] || '3X-UI';
const host = window.location.hostname;
document.title = host ? `${host} - ${title}` : title;
}, [pathname]);
}

View File

@@ -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 };
}

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,

View File

@@ -0,0 +1,10 @@
import { Outlet } from 'react-router-dom';
import { useWebSocketBridge } from '@/api/websocketBridge';
import { usePageTitle } from '@/hooks/usePageTitle';
export default function PanelLayout() {
useWebSocketBridge();
usePageTitle();
return <Outlet />;
}

View File

@@ -1,15 +1,15 @@
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage';
import { QueryProvider } from '@/api/QueryProvider';
import { router } from '@/routes';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
@@ -21,7 +21,9 @@ readyI18n().then(() => {
if (root) {
createRoot(root).render(
<ThemeProvider>
<ApiDocsPage />
<QueryProvider>
<RouterProvider router={router} />
</QueryProvider>
</ThemeProvider>,
);
}

View File

@@ -8,11 +8,28 @@
.api-docs-page.is-dark {
--bg-page: #1a1b1f;
--bg-card: #23252b;
--sw-bg: #1f2026;
--sw-bg-soft: #25272e;
--sw-bg-input: #15161a;
--sw-bg-code: #0d0e12;
--sw-border: rgba(255, 255, 255, 0.08);
--sw-border-strong: rgba(255, 255, 255, 0.15);
--sw-text: rgba(255, 255, 255, 0.88);
--sw-text-muted: rgba(255, 255, 255, 0.6);
--sw-text-dim: rgba(255, 255, 255, 0.45);
--sw-accent: #58a6ff;
color-scheme: dark;
}
.api-docs-page.is-dark.is-ultra {
--bg-page: #000;
--bg-card: #101013;
--sw-bg: #0a0a0d;
--sw-bg-soft: #131316;
--sw-bg-input: #050507;
--sw-bg-code: #000;
--sw-border: rgba(255, 255, 255, 0.06);
--sw-border-strong: rgba(255, 255, 255, 0.12);
}
.api-docs-page .content-shell {
@@ -20,273 +37,396 @@
}
.api-docs-page .content-area {
padding: 24px;
padding: 16px;
max-width: 100%;
}
@media (max-width: 768px) {
.api-docs-page .content-area {
padding: 16px 12px 12px;
padding-top: 64px;
padding: 8px;
padding-top: 56px;
}
}
.docs-wrapper {
max-width: 1100px;
margin: 0 auto;
}
.docs-header {
margin-bottom: 20px;
padding: 24px;
.api-docs-page .docs-wrapper {
background: var(--bg-card);
border: 1px solid rgba(128, 128, 128, 0.12);
border-radius: 10px;
}
.docs-title {
font-size: 28px;
font-weight: 800;
margin: 0 0 8px;
color: rgba(0, 0, 0, 0.88);
letter-spacing: -0.3px;
}
.docs-lead {
margin: 0;
color: rgba(0, 0, 0, 0.65);
line-height: 1.65;
font-size: 14px;
}
.docs-lead code,
.token-hint code {
background: rgba(128, 128, 128, 0.12);
padding: 1px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
}
.token-card,
.curl-card {
margin-bottom: 16px;
}
.token-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 10px;
min-height: 32px;
}
.token-card-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
}
.token-hint {
margin: 10px 0 0;
color: rgba(0, 0, 0, 0.55);
font-size: 12.5px;
line-height: 1.55;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.search-bar {
flex: 1;
min-width: 200px;
max-width: 480px;
}
.match-count {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
white-space: nowrap;
}
.toc-nav {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px 12px;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid rgba(128, 128, 128, 0.12);
border-radius: 8px;
margin-bottom: 16px;
border: 1px solid rgba(128, 128, 128, 0.12);
overflow: hidden;
}
.toc-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: rgba(0, 0, 0, 0.5);
padding-top: 3px;
flex-shrink: 0;
.api-docs-page .swagger-ui {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.toc-links {
display: flex;
flex-wrap: wrap;
gap: 6px;
/* ──────────────────────────────────────────────────────────────────
Dark mode — Swagger UI does not ship a dark theme, so every visual
surface needs an explicit override. Method-color chips (GET / POST /
…) are left untouched because they carry meaning at a glance.
────────────────────────────────────────────────────────────────── */
.api-docs-page.is-dark .swagger-ui,
.api-docs-page.is-dark .swagger-ui .info .title,
.api-docs-page.is-dark .swagger-ui .info .title small pre,
.api-docs-page.is-dark .swagger-ui .info p,
.api-docs-page.is-dark .swagger-ui .info li,
.api-docs-page.is-dark .swagger-ui .info table,
.api-docs-page.is-dark .swagger-ui .opblock-tag,
.api-docs-page.is-dark .swagger-ui .opblock-tag small,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-path,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-path__deprecated,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-description,
.api-docs-page.is-dark .swagger-ui .opblock-description-wrapper p,
.api-docs-page.is-dark .swagger-ui .opblock-external-docs-wrapper p,
.api-docs-page.is-dark .swagger-ui .opblock-title_normal p,
.api-docs-page.is-dark .swagger-ui table thead tr td,
.api-docs-page.is-dark .swagger-ui table thead tr th,
.api-docs-page.is-dark .swagger-ui table tbody tr td,
.api-docs-page.is-dark .swagger-ui .parameter__name,
.api-docs-page.is-dark .swagger-ui .parameter__type,
.api-docs-page.is-dark .swagger-ui .parameter__in,
.api-docs-page.is-dark .swagger-ui .parameter__extension,
.api-docs-page.is-dark .swagger-ui .response-col_status,
.api-docs-page.is-dark .swagger-ui .response-col_description,
.api-docs-page.is-dark .swagger-ui .response-col_links,
.api-docs-page.is-dark .swagger-ui .responses-inner h4,
.api-docs-page.is-dark .swagger-ui .responses-inner h5,
.api-docs-page.is-dark .swagger-ui label,
.api-docs-page.is-dark .swagger-ui .tab li,
.api-docs-page.is-dark .swagger-ui .tab li button,
.api-docs-page.is-dark .swagger-ui .markdown,
.api-docs-page.is-dark .swagger-ui .markdown p,
.api-docs-page.is-dark .swagger-ui .markdown li,
.api-docs-page.is-dark .swagger-ui .renderedmarkdown p,
.api-docs-page.is-dark .swagger-ui .renderedmarkdown li,
.api-docs-page.is-dark .swagger-ui .model-title,
.api-docs-page.is-dark .swagger-ui .model,
.api-docs-page.is-dark .swagger-ui .model-toggle:after,
.api-docs-page.is-dark .swagger-ui section.models h4,
.api-docs-page.is-dark .swagger-ui section.models h5,
.api-docs-page.is-dark .swagger-ui .auth-container h4,
.api-docs-page.is-dark .swagger-ui .auth-container h6,
.api-docs-page.is-dark .swagger-ui .scopes h2,
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-header h3,
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content h4,
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content p,
.api-docs-page.is-dark .swagger-ui .servers-title {
color: var(--sw-text);
}
.toc-link {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12.5px;
color: rgba(0, 0, 0, 0.65);
background: rgba(128, 128, 128, 0.06);
border: 1px solid transparent;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
.api-docs-page.is-dark .swagger-ui .opblock-tag small,
.api-docs-page.is-dark .swagger-ui .opblock-summary-description,
.api-docs-page.is-dark .swagger-ui .parameter__in,
.api-docs-page.is-dark .swagger-ui .parameter__type,
.api-docs-page.is-dark .swagger-ui .parameter__extension,
.api-docs-page.is-dark .swagger-ui .opblock-title_normal small,
.api-docs-page.is-dark .swagger-ui .servers > label,
.api-docs-page.is-dark .swagger-ui .servers > label span,
.api-docs-page.is-dark .swagger-ui .response-control-media-type__accept-message {
color: var(--sw-text-muted);
}
.toc-link:hover {
background: rgba(22, 119, 255, 0.08);
color: #1677ff;
border-color: rgba(22, 119, 255, 0.2);
.api-docs-page.is-dark .swagger-ui .opblock-tag {
border-bottom-color: var(--sw-border);
}
.toc-link.active {
background: rgba(22, 119, 255, 0.12);
color: #1677ff;
border-color: rgba(22, 119, 255, 0.3);
font-weight: 600;
.api-docs-page.is-dark .swagger-ui .opblock {
background: var(--sw-bg-soft);
border-color: var(--sw-border);
box-shadow: none;
}
.toc-icon {
font-size: 13px;
opacity: 0.8;
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header {
background: rgba(255, 255, 255, 0.03);
box-shadow: inset 0 -1px 0 var(--sw-border);
}
.toc-text {
font-size: 12.5px;
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header h4,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header label {
color: var(--sw-text);
}
.toc-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
font-size: 10.5px;
font-weight: 700;
background: rgba(22, 119, 255, 0.12);
color: #1677ff;
line-height: 1;
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary {
border-bottom-color: var(--sw-border);
}
.toc-link.active .toc-badge {
background: #1677ff;
color: #fff;
}
body.dark .docs-title {
.api-docs-page.is-dark .swagger-ui .opblock-body pre.microlight,
.api-docs-page.is-dark .swagger-ui .highlight-code,
.api-docs-page.is-dark .swagger-ui .microlight,
.api-docs-page.is-dark .swagger-ui pre.example,
.api-docs-page.is-dark .swagger-ui code {
background: var(--sw-bg-code);
color: rgba(255, 255, 255, 0.92);
}
html[data-theme='ultra-dark'] .docs-title {
color: rgba(255, 255, 255, 0.95);
}
body.dark .docs-header {
background: #252526;
border-color: rgba(255, 255, 255, 0.08);
}
html[data-theme='ultra-dark'] .docs-header {
background: #0a0a0a;
border-color: rgba(255, 255, 255, 0.06);
}
body.dark .docs-lead,
body.dark .token-hint {
color: rgba(255, 255, 255, 0.7);
}
html[data-theme='ultra-dark'] .docs-lead,
html[data-theme='ultra-dark'] .token-hint {
color: rgba(255, 255, 255, 0.75);
}
body.dark .docs-lead code,
body.dark .token-hint code {
background: rgba(255, 255, 255, 0.1);
}
html[data-theme='ultra-dark'] .docs-lead code,
html[data-theme='ultra-dark'] .token-hint code {
background: rgba(255, 255, 255, 0.12);
}
body.dark .toc-nav {
background: #252526;
border-color: rgba(255, 255, 255, 0.08);
}
html[data-theme='ultra-dark'] .toc-nav {
background: #0a0a0a;
border-color: rgba(255, 255, 255, 0.06);
}
body.dark .toc-label {
color: rgba(255, 255, 255, 0.55);
}
html[data-theme='ultra-dark'] .toc-label {
color: rgba(255, 255, 255, 0.6);
}
body.dark .toc-link {
color: rgba(255, 255, 255, 0.65);
.api-docs-page.is-dark .swagger-ui .highlight-code .copy-to-clipboard {
background: rgba(255, 255, 255, 0.06);
}
html[data-theme='ultra-dark'] .toc-link {
.api-docs-page.is-dark .swagger-ui input[type=text],
.api-docs-page.is-dark .swagger-ui input[type=password],
.api-docs-page.is-dark .swagger-ui input[type=search],
.api-docs-page.is-dark .swagger-ui input[type=email],
.api-docs-page.is-dark .swagger-ui input[type=file],
.api-docs-page.is-dark .swagger-ui textarea {
background: var(--sw-bg-input);
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui select {
background-color: var(--sw-bg-input);
background-image: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='%23b7bcbf' d='M13.418 7.859a.695.695 0 0 1 .978 0 .68.68 0 0 1 0 .969l-3.908 3.83a.697.697 0 0 1-.979 0l-3.908-3.83a.68.68 0 0 1 0-.969.695.695 0 0 1 .978 0L10 11z'/></svg>");
background-position: right 10px center;
background-repeat: no-repeat;
background-size: 20px;
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui select option {
background-color: var(--sw-bg-input);
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.api-docs-page.is-dark .swagger-ui input::placeholder,
.api-docs-page.is-dark .swagger-ui textarea::placeholder {
color: var(--sw-text-dim);
}
.api-docs-page.is-dark .swagger-ui .scheme-container {
background: var(--sw-bg-soft);
box-shadow: inset 0 -1px 0 var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .auth-wrapper .authorize {
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .auth-wrapper .authorize svg {
fill: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .model-box,
.api-docs-page.is-dark .swagger-ui section.models {
background: var(--sw-bg-soft);
border-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui section.models.is-open h4 {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui section.models .model-container {
background: rgba(255, 255, 255, 0.02);
border-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui section.models .model-container:hover {
background: rgba(255, 255, 255, 0.04);
}
body.dark .toc-link:hover {
background: rgba(88, 166, 255, 0.12);
color: #58a6ff;
border-color: rgba(88, 166, 255, 0.25);
.api-docs-page.is-dark .swagger-ui .prop-type,
.api-docs-page.is-dark .swagger-ui .prop-format {
color: var(--sw-accent);
}
body.dark .toc-link.active {
background: rgba(88, 166, 255, 0.15);
color: #58a6ff;
border-color: rgba(88, 166, 255, 0.35);
.api-docs-page.is-dark .swagger-ui .property.primitive {
color: var(--sw-text-muted);
}
body.dark .toc-badge {
background: rgba(88, 166, 255, 0.15);
color: #58a6ff;
.api-docs-page.is-dark .swagger-ui .opblock-title_normal h4 {
color: var(--sw-text);
border-bottom-color: var(--sw-border);
}
body.dark .toc-link.active .toc-badge {
background: #58a6ff;
.api-docs-page.is-dark .swagger-ui table.parameters,
.api-docs-page.is-dark .swagger-ui table.responses-table,
.api-docs-page.is-dark .swagger-ui table thead tr {
border-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui table tbody tr td {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui table thead tr {
border-bottom-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .response-col_status {
font-weight: 600;
}
.api-docs-page.is-dark .swagger-ui .btn {
background: rgba(255, 255, 255, 0.06);
color: var(--sw-text);
border-color: var(--sw-border-strong);
box-shadow: none;
}
.api-docs-page.is-dark .swagger-ui .btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.api-docs-page.is-dark .swagger-ui .btn.execute {
background: var(--sw-accent);
color: #0d1117;
border-color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui .btn.execute:hover {
background: #79b9ff;
}
.api-docs-page.is-dark .swagger-ui .btn.authorize {
color: #52c41a;
border-color: rgba(82, 196, 26, 0.4);
background: rgba(82, 196, 26, 0.08);
}
.api-docs-page.is-dark .swagger-ui .btn.authorize svg {
fill: #52c41a;
}
.api-docs-page.is-dark .swagger-ui .authorization__btn svg,
.api-docs-page.is-dark .swagger-ui .expand-operation svg,
.api-docs-page.is-dark .swagger-ui .opblock-control-arrow svg {
fill: var(--sw-text);
opacity: 1;
}
.api-docs-page.is-dark .swagger-ui .authorization__btn .locked,
.api-docs-page.is-dark .swagger-ui .authorization__btn .unlocked {
opacity: 1;
}
.api-docs-page.is-dark .swagger-ui .btn.cancel {
color: #ff7875;
border-color: rgba(255, 120, 117, 0.4);
background: rgba(255, 120, 117, 0.08);
}
.api-docs-page.is-dark .swagger-ui .btn.btn-clear,
.api-docs-page.is-dark .swagger-ui .btn-clear,
.api-docs-page.is-dark .swagger-ui .try-out__btn {
color: var(--sw-text);
background: rgba(255, 255, 255, 0.06);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .btn.btn-clear:hover,
.api-docs-page.is-dark .swagger-ui .btn-clear:hover,
.api-docs-page.is-dark .swagger-ui .try-out__btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.api-docs-page.is-dark .swagger-ui .filter .operation-filter-input {
background: var(--sw-bg-input);
border-color: var(--sw-border-strong);
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux {
background: var(--sw-bg);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-header {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content {
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .arrow,
.api-docs-page.is-dark .swagger-ui svg.arrow {
fill: var(--sw-text-muted);
}
.api-docs-page.is-dark .swagger-ui .opblock-summary-control:focus {
outline-color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui a,
.api-docs-page.is-dark .swagger-ui .info a,
.api-docs-page.is-dark .swagger-ui .info hgroup.main a,
.api-docs-page.is-dark .swagger-ui .info .base-url,
.api-docs-page.is-dark .swagger-ui .info__contact a,
.api-docs-page.is-dark .swagger-ui .info__license a,
.api-docs-page.is-dark .swagger-ui .info__tos a {
color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui a:hover {
color: #79b9ff;
}
.api-docs-page.is-dark .swagger-ui .info .title small {
background: rgba(255, 255, 255, 0.08);
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .info .title small pre {
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .response-control-media-type--accept-controller select {
border-color: rgba(82, 196, 26, 0.5);
}
.api-docs-page.is-dark .swagger-ui .loading-container .loading:before {
border-color: var(--sw-accent) transparent transparent;
}
.api-docs-page.is-dark .swagger-ui .json-schema-form-item input,
.api-docs-page.is-dark .swagger-ui .json-schema-form-item select {
background: var(--sw-bg-input);
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .topbar {
background: var(--sw-bg);
}
.api-docs-page.is-dark .swagger-ui .information-container {
background: transparent;
}
.api-docs-page.is-dark .swagger-ui .opblock-summary-method {
text-shadow: none;
}
.api-docs-page.is-dark .swagger-ui .auth-btn-wrapper {
border-top-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .servers .computed-url,
.api-docs-page.is-dark .swagger-ui .computed-url {
background: var(--sw-bg-code);
color: var(--sw-text);
border: 1px solid var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .computed-url code,
.api-docs-page.is-dark .swagger-ui .servers .computed-url code {
background: transparent;
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .errors-wrapper {
background: rgba(255, 77, 79, 0.08);
border-color: rgba(255, 77, 79, 0.3);
}
.api-docs-page.is-dark .swagger-ui .errors-wrapper .errors h4,
.api-docs-page.is-dark .swagger-ui .errors-wrapper .errors small {
color: var(--sw-text);
}

View File

@@ -1,143 +1,19 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ComponentType, MouseEvent } from 'react';
import { Button, Card, ConfigProvider, Input, Layout, Space } from 'antd';
import {
ApiOutlined,
CloudServerOutlined,
ClusterOutlined,
CompressOutlined,
ExpandOutlined,
GlobalOutlined,
KeyOutlined,
LinkOutlined,
NodeIndexOutlined,
SafetyCertificateOutlined,
SaveOutlined,
SearchOutlined,
SettingOutlined,
WifiOutlined,
} from '@ant-design/icons';
import { useMemo } from 'react';
import { ConfigProvider, Layout } from 'antd';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/components/AppSidebar';
import { sections as allSections } from './endpoints.js';
import EndpointSection from './EndpointSection';
import type { Section } from './EndpointSection';
import CodeBlock from './CodeBlock';
import '@/styles/page-cards.css';
import './ApiDocsPage.css';
const sectionIcons: Record<string, ComponentType<{ className?: string }>> = {
authentication: SafetyCertificateOutlined,
inbounds: NodeIndexOutlined,
server: CloudServerOutlined,
nodes: ClusterOutlined,
'custom-geo': GlobalOutlined,
backup: SaveOutlined,
settings: SettingOutlined,
'api-tokens': KeyOutlined,
'xray-settings': WifiOutlined,
subscription: LinkOutlined,
websocket: ApiOutlined,
};
const curlExample = `curl -X GET \\
-H "Authorization: Bearer YOUR_API_TOKEN" \\
-H "Accept: application/json" \\
https://your-panel.example.com/panel/api/inbounds/list`;
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
const settingsHref = `${basePath}panel/settings#security`;
const endpointCount = (allSections as Section[]).reduce(
(sum, s) => sum + s.endpoints.length,
0,
);
const openApiUrl = `${basePath}panel/api/openapi.json`;
export default function ApiDocsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const [searchQuery, setSearchQuery] = useState('');
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(() => new Set());
const [activeSection, setActiveSection] = useState('');
const sections = useMemo<Section[]>(() => {
const q = searchQuery.toLowerCase().trim();
if (!q) return allSections as Section[];
return (allSections as Section[])
.map((s) => ({
...s,
endpoints: s.endpoints.filter((e) =>
e.path.toLowerCase().includes(q)
|| e.summary?.toLowerCase().includes(q)
|| e.method.toLowerCase().includes(q),
),
}))
.filter((s) => s.endpoints.length > 0);
}, [searchQuery]);
const visibleEndpoints = useMemo(
() => sections.reduce((sum, s) => sum + s.endpoints.length, 0),
[sections],
);
const toggleSection = useCallback((id: string) => {
setCollapsedSections((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const expandAll = useCallback(() => setCollapsedSections(new Set()), []);
const collapseAll = useCallback(
() => setCollapsedSections(new Set((allSections as Section[]).map((s) => s.id))),
[],
);
const scrollToSection = useCallback((id: string) => (e: MouseEvent) => {
e.preventDefault();
const el = document.getElementById(id);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (window.location.hash !== `#${id}`) {
history.replaceState(null, '', `#${id}`);
}
}, []);
useEffect(() => {
const onHashChange = () => {
const id = window.location.hash.slice(1);
if (!id) return;
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
};
requestAnimationFrame(onHashChange);
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
useEffect(() => {
const onScroll = () => {
const toc = document.querySelector('.toc-nav');
const tocHeight = toc instanceof HTMLElement ? toc.offsetHeight : 56;
let current = '';
for (const s of sections) {
const el = document.getElementById(s.id);
if (!el) continue;
const rect = el.getBoundingClientRect();
if (rect.top <= tocHeight + 20) {
current = s.id;
}
}
setActiveSection(current);
};
window.addEventListener('scroll', onScroll, { passive: true });
requestAnimationFrame(onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [sections]);
const pageClass = useMemo(() => {
const classes = ['api-docs-page'];
if (isDark) classes.push('is-dark');
@@ -148,96 +24,17 @@ export default function ApiDocsPage() {
return (
<ConfigProvider theme={antdThemeConfig}>
<Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} />
<AppSidebar />
<Layout className="content-shell">
<Layout.Content className="content-area">
<div className="docs-wrapper">
<header className="docs-header">
<h1 className="docs-title">API Documentation</h1>
<p className="docs-lead">
The 3x-ui panel exposes a REST API under <code>/panel/api/</code>. Authenticate with the panel session
cookie, or with the <code>Authorization: Bearer &lt;token&gt;</code> header below. Every endpoint
returns a uniform <code>{'{ success, msg, obj }'}</code> envelope unless otherwise noted.
</p>
</header>
<Card className="token-card" size="small">
<div className="token-card-head">
<div className="token-card-title">
<KeyOutlined />
<span>API Tokens</span>
</div>
<Button type="primary" size="small" href={settingsHref}>
Manage tokens
</Button>
</div>
<p className="token-hint">
Create, enable, or revoke named Bearer tokens in{' '}
<a href={settingsHref}>Settings Security</a>. Send each request as{' '}
<code>Authorization: Bearer &lt;token&gt;</code>. Token-authenticated callers skip CSRF and don&apos;t
need a session cookie. Deleting a token revokes it immediately running bots will need a new one.
</p>
</Card>
<Card className="curl-card" size="small" title="Quick example">
<CodeBlock code={curlExample} lang="text" />
</Card>
<div className="toolbar">
<Input
className="search-bar"
prefix={<SearchOutlined />}
placeholder="Search endpoints by path, method, or description…"
allowClear
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<span className="match-count">
{visibleEndpoints} / {endpointCount} endpoints
</span>
)}
<Space size="small">
<Button size="small" icon={<ExpandOutlined />} onClick={expandAll}>
Expand all
</Button>
<Button size="small" icon={<CompressOutlined />} onClick={collapseAll}>
Collapse all
</Button>
</Space>
</div>
<nav className="toc-nav">
<span className="toc-label">On this page:</span>
<div className="toc-links">
{sections.map((s) => {
const Icon = sectionIcons[s.id];
return (
<a
key={s.id}
className={`toc-link${activeSection === s.id ? ' active' : ''}`}
href={`#${s.id}`}
onClick={scrollToSection(s.id)}
>
{Icon && <Icon />}
<span className="toc-text">{s.title}</span>
<span className="toc-badge">{s.endpoints.length}</span>
</a>
);
})}
</div>
</nav>
{sections.map((s) => (
<EndpointSection
key={s.id}
section={s}
icon={sectionIcons[s.id]}
collapsed={collapsedSections.has(s.id)}
onToggle={() => toggleSection(s.id)}
/>
))}
<SwaggerUI
url={openApiUrl}
docExpansion="list"
deepLinking={false}
tryItOutEnabled
/>
</div>
</Layout.Content>
</Layout>

View File

@@ -1,107 +0,0 @@
.code-block-wrapper {
position: relative;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(128, 128, 128, 0.15);
}
.code-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
background: rgba(128, 128, 128, 0.06);
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
}
.lang-badge {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
color: rgba(0, 0, 0, 0.4);
text-transform: uppercase;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.copy-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 4px;
background: rgba(255, 255, 255, 0.7);
color: rgba(0, 0, 0, 0.45);
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.copy-btn:hover {
background: #fff;
color: #1677ff;
border-color: #1677ff;
}
.copy-btn.copied {
background: #52c41a;
color: #fff;
border-color: #52c41a;
}
.code-block {
background: rgba(128, 128, 128, 0.04);
padding: 10px 12px;
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
border: none;
border-radius: 0;
}
.json-key { color: #0550ae; }
.json-string { color: #116329; }
.json-number { color: #9a6700; }
.json-boolean { color: #cf222e; }
.json-null { color: #8250df; }
body.dark .code-block-wrapper {
border-color: rgba(255, 255, 255, 0.1);
}
body.dark .code-toolbar {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.06);
}
body.dark .lang-badge {
color: rgba(255, 255, 255, 0.4);
}
body.dark .code-block {
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.88);
}
body.dark .json-key { color: #79c0ff; }
body.dark .json-string { color: #7ee787; }
body.dark .json-number { color: #d29922; }
body.dark .json-boolean { color: #ff7b72; }
body.dark .json-null { color: #d2a8ff; }
body.dark .copy-btn {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.45);
border-color: rgba(255, 255, 255, 0.12);
}
body.dark .copy-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #58a6ff;
border-color: #58a6ff;
}

View File

@@ -1,69 +0,0 @@
import { useMemo, useState } from 'react';
import { message } from 'antd';
import { CheckOutlined, CopyOutlined } from '@ant-design/icons';
import { ClipboardManager } from '@/utils';
import './CodeBlock.css';
interface CodeBlockProps {
code?: string;
lang?: string;
}
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function highlightJson(str: string): string {
const escaped = escapeHtml(str);
return escaped.replace(
/("(?:[^"\\]|\\.)*")\s*(:)|("(?:[^"\\]|\\.)*")|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)\b|(true|false)|(null)|([{}[\]])/g,
(_m, key, colon, string, number, bool, nil) => {
if (colon) return `<span class="json-key">${key}</span>${colon}`;
if (string) return `<span class="json-string">${string}</span>`;
if (number) return `<span class="json-number">${number}</span>`;
if (bool) return `<span class="json-boolean">${bool}</span>`;
if (nil) return `<span class="json-null">${nil}</span>`;
return _m;
},
);
}
export default function CodeBlock({ code = '', lang = 'json' }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const [messageApi, messageContextHolder] = message.useMessage();
const highlighted = useMemo(
() => (lang === 'json' ? highlightJson(code) : escapeHtml(code)),
[code, lang],
);
async function copyCode() {
const ok = await ClipboardManager.copyText(code);
if (ok) {
setCopied(true);
messageApi.success('Copied');
window.setTimeout(() => setCopied(false), 2000);
} else {
messageApi.error('Copy failed');
}
}
return (
<div className="code-block-wrapper">
{messageContextHolder}
<div className="code-toolbar">
<span className="lang-badge">{lang.toUpperCase()}</span>
<button
className={`copy-btn${copied ? ' copied' : ''}`}
onClick={copyCode}
title={copied ? 'Copied' : 'Copy'}
>
{copied ? <CheckOutlined /> : <CopyOutlined />}
</button>
</div>
<pre className={`code-block lang-${lang}`}>
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
</pre>
</div>
);
}

View File

@@ -1,93 +0,0 @@
.endpoint-row {
padding: 14px 8px;
margin: 0 -8px;
transition: background 0.15s;
border-radius: 6px;
}
.endpoint-row:hover {
background: rgba(128, 128, 128, 0.03);
}
.endpoint-row + .endpoint-row {
border-top: 1px solid rgba(128, 128, 128, 0.1);
}
.endpoint-header {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.method-tag {
font-weight: 700;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
letter-spacing: 0.5px;
min-width: 56px;
text-align: center;
text-transform: uppercase;
border-radius: 4px;
padding: 2px 8px;
line-height: 1.6;
}
.endpoint-path {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13.5px;
word-break: break-all;
color: rgba(0, 0, 0, 0.8);
background: rgba(128, 128, 128, 0.06);
padding: 2px 8px;
border-radius: 4px;
}
.endpoint-summary {
margin: 8px 0 0;
color: rgba(0, 0, 0, 0.6);
line-height: 1.6;
font-size: 13.5px;
}
.endpoint-block {
margin-top: 14px;
}
.block-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 6px;
}
.error-label {
color: #cf222e;
}
body.dark .endpoint-row:hover {
background: rgba(255, 255, 255, 0.02);
}
body.dark .endpoint-row + .endpoint-row {
border-top-color: rgba(255, 255, 255, 0.08);
}
body.dark .endpoint-path {
color: rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.05);
}
body.dark .endpoint-summary {
color: rgba(255, 255, 255, 0.65);
}
body.dark .block-label {
color: rgba(255, 255, 255, 0.45);
}
body.dark .error-label {
color: #ff7b72;
}

View File

@@ -1,84 +0,0 @@
import { Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { methodColors, safeInlineHtml } from './endpoints.js';
import CodeBlock from './CodeBlock';
import './EndpointRow.css';
interface Param {
name: string;
in?: string;
type?: string;
desc?: string;
}
export interface Endpoint {
method: string;
path: string;
summary?: string;
params?: Param[];
body?: string;
response?: string;
errorResponse?: string;
}
const paramColumns: ColumnsType<Param> = [
{ title: 'Name', dataIndex: 'name', key: 'name', width: 180 },
{ title: 'In', dataIndex: 'in', key: 'in', width: 100 },
{ title: 'Type', dataIndex: 'type', key: 'type', width: 120 },
{ title: 'Description', dataIndex: 'desc', key: 'desc' },
];
export default function EndpointRow({ endpoint }: { endpoint: Endpoint }) {
const tagColor = (methodColors as Record<string, string>)[endpoint.method] || 'default';
const hasParams = Array.isArray(endpoint.params) && endpoint.params.length > 0;
return (
<div className="endpoint-row">
<div className="endpoint-header">
<Tag color={tagColor} className="method-tag">{endpoint.method}</Tag>
<code className="endpoint-path">{endpoint.path}</code>
</div>
{endpoint.summary && (
<p
className="endpoint-summary"
dangerouslySetInnerHTML={{ __html: safeInlineHtml(endpoint.summary) }}
/>
)}
{hasParams && (
<div className="endpoint-block">
<div className="block-label">Parameters</div>
<Table
columns={paramColumns}
dataSource={endpoint.params}
pagination={false}
size="small"
rowKey="name"
/>
</div>
)}
{endpoint.body && (
<div className="endpoint-block">
<div className="block-label">Request body</div>
<CodeBlock code={endpoint.body} lang="json" />
</div>
)}
{endpoint.response && (
<div className="endpoint-block">
<div className="block-label">Response</div>
<CodeBlock code={endpoint.response} lang="json" />
</div>
)}
{endpoint.errorResponse && (
<div className="endpoint-block">
<div className="block-label error-label">Error response</div>
<CodeBlock code={endpoint.errorResponse} lang="json" />
</div>
)}
</div>
);
}

View File

@@ -1,129 +0,0 @@
.api-section {
background: #fff;
border: 1px solid rgba(128, 128, 128, 0.12);
border-radius: 8px;
padding: 20px 24px;
margin-bottom: 16px;
transition: box-shadow 0.2s, border-color 0.2s;
}
.api-section:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.section-header:hover .collapse-icon,
.section-header:hover .section-icon {
color: #1677ff;
}
.section-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.collapse-icon {
font-size: 12px;
color: rgba(0, 0, 0, 0.4);
transition: color 0.2s;
}
.section-icon {
font-size: 18px;
color: rgba(0, 0, 0, 0.45);
transition: color 0.2s;
}
.section-title {
font-size: 20px;
font-weight: 700;
margin: 0;
color: rgba(0, 0, 0, 0.88);
}
.endpoint-count {
font-size: 11px;
font-weight: 600;
color: rgba(0, 0, 0, 0.45);
white-space: nowrap;
background: rgba(128, 128, 128, 0.08);
padding: 3px 10px;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.section-description {
margin: 12px 0 14px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.6;
}
.sub-header-block {
margin-bottom: 14px;
}
.section-block-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(0, 0, 0, 0.5);
margin-bottom: 6px;
}
.endpoints {
padding-top: 8px;
border-top: 1px solid rgba(128, 128, 128, 0.1);
}
.endpoints > :first-child {
padding-top: 0;
}
body.dark .api-section {
background: #252526;
border-color: rgba(255, 255, 255, 0.08);
}
body.dark .api-section:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
}
html[data-theme='ultra-dark'] .api-section {
background: #0a0a0a;
border-color: rgba(255, 255, 255, 0.06);
}
html[data-theme='ultra-dark'] .api-section:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
}
body.dark .section-title {
color: rgba(255, 255, 255, 0.92);
}
body.dark .section-icon {
color: rgba(255, 255, 255, 0.5);
}
body.dark .section-description {
color: rgba(255, 255, 255, 0.7);
}
body.dark .section-block-label {
color: rgba(255, 255, 255, 0.55);
}
body.dark .endpoint-count {
color: rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.06);
}

View File

@@ -1,90 +0,0 @@
import type { ComponentType } from 'react';
import { Table } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { DownOutlined, RightOutlined } from '@ant-design/icons';
import EndpointRow from './EndpointRow';
import type { Endpoint } from './EndpointRow';
import { safeInlineHtml } from './endpoints.js';
import './EndpointSection.css';
interface SubHeader {
name: string;
desc?: string;
}
export interface Section {
id: string;
title: string;
description?: string;
endpoints: Endpoint[];
subHeader?: SubHeader[];
}
interface EndpointSectionProps {
section: Section;
icon?: ComponentType<{ className?: string }> | null;
collapsed?: boolean;
onToggle?: () => void;
}
const subHeaderColumns: ColumnsType<SubHeader> = [
{ title: 'Header', dataIndex: 'name', key: 'name', width: 240 },
{
title: 'Description',
dataIndex: 'desc',
key: 'desc',
render: (value: string) => (
<span dangerouslySetInnerHTML={{ __html: safeInlineHtml(value || '') }} />
),
},
];
export default function EndpointSection({
section,
icon: Icon = null,
collapsed = false,
onToggle,
}: EndpointSectionProps) {
const endpointLabel = section.endpoints.length === 1
? '1 endpoint'
: `${section.endpoints.length} endpoints`;
return (
<section id={section.id} className="api-section">
<div className="section-header" onClick={onToggle}>
<div className="section-header-left">
{collapsed ? <RightOutlined className="collapse-icon" /> : <DownOutlined className="collapse-icon" />}
{Icon && <Icon className="section-icon" />}
<h2 className="section-title">{section.title}</h2>
</div>
<span className="endpoint-count">{endpointLabel}</span>
</div>
{section.description && !collapsed && (
<p
className="section-description"
dangerouslySetInnerHTML={{ __html: safeInlineHtml(section.description) }}
/>
)}
{section.subHeader && !collapsed && (
<div className="sub-header-block">
<div className="section-block-label">Response headers</div>
<Table
columns={subHeaderColumns}
dataSource={section.subHeader}
pagination={false}
size="small"
rowKey="name"
/>
</div>
)}
<div className="endpoints" style={{ display: collapsed ? 'none' : undefined }}>
{section.endpoints.map((endpoint, idx) => (
<EndpointRow key={idx} endpoint={endpoint} />
))}
</div>
</section>
);
}

View File

@@ -61,8 +61,6 @@ const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
import '@/styles/page-cards.css';
import './ClientsPage.css';
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
const FILTER_STATE_KEY = 'clientsFilterState';
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
@@ -108,14 +106,13 @@ export default function ClientsPage() {
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
create, update, remove, removeMany, bulkAdjust, attach, detach,
resetTraffic, resetAllTraffics, delDepleted, setEnable,
applyTrafficEvent, applyClientStatsEvent, applyInvalidate,
applyTrafficEvent, applyClientStatsEvent,
hydrate,
} = useClients();
useWebSocket({
traffic: applyTrafficEvent,
client_stats: applyClientStatsEvent,
invalidate: applyInvalidate,
});
const [togglingEmail, setTogglingEmail] = useState<string | null>(null);
@@ -614,7 +611,7 @@ export default function ClientsPage() {
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} />
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">

View File

@@ -60,7 +60,7 @@ import { DBInbound } from '@/models/dbinbound.js';
import FinalMaskForm from '@/components/FinalMaskForm';
import DateTimePicker from '@/components/DateTimePicker';
import JsonEditor from '@/components/JsonEditor';
import type { NodeRecord } from '@/hooks/useNodes';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import './InboundFormModal.css';
const { TextArea } = Input;

View File

@@ -33,7 +33,7 @@ import {
import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import InfinityIcon from '@/components/InfinityIcon';
import { useDatepicker } from '@/hooks/useDatepicker';
import type { NodeRecord } from '@/hooks/useNodes';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import './InboundList.css';
type ProtocolFlags = {

View File

@@ -25,7 +25,7 @@ import { coerceInboundJsonField } from '@/models/dbinbound.js';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useWebSocket } from '@/hooks/useWebSocket';
import { useNodes } from '@/hooks/useNodes';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import AppSidebar from '@/components/AppSidebar';
import CustomStatistic from '@/components/CustomStatistic';
const TextModal = lazy(() => import('@/components/TextModal'));
@@ -74,20 +74,17 @@ export default function InboundsPage() {
remarkModel,
refresh,
hydrateInbound,
fetchDefaultSettings,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
applyInboundsEvent,
} = useInbounds();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { nodes: nodesList } = useNodes();
const { nodes: nodesList } = useNodesQuery();
const nodesById = useMemo(() => {
const map = new Map<number, ReturnType<typeof useNodes>['nodes'][number]>();
const map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
for (const n of nodesList || []) map.set(n.id, n);
return map;
}, [nodesList]);
@@ -105,15 +102,8 @@ export default function InboundsPage() {
useWebSocket({
traffic: applyTrafficEvent,
client_stats: applyClientStatsEvent,
invalidate: applyInvalidate,
inbounds: applyInboundsEvent,
});
useEffect(() => {
fetchDefaultSettings().then(() => refresh());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [formDbInbound, setFormDbInbound] = useState<any>(null);
@@ -449,15 +439,12 @@ export default function InboundsPage() {
}
}, [hydrateInbound, openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone, messageApi]);
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
const requestUri = typeof window !== 'undefined' ? window.location.pathname : '';
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
<AppSidebar basePath={basePath} requestUri={requestUri} />
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">

View File

@@ -1,9 +1,11 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { DBInbound } from '@/models/dbinbound.js';
import { Protocols } from '@/models/inbound.js';
import { setDatepicker } from '@/hooks/useDatepicker';
import { keys } from '@/api/queryKeys';
export interface SubSettings {
enable: boolean;
@@ -25,6 +27,27 @@ interface ClientRollup {
comments: Map<string, string>;
}
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
msg?: string;
}
interface DefaultsPayload {
expireDiff?: number;
trafficDiff?: number;
tgBotEnable?: boolean;
subEnable?: boolean;
subTitle?: string;
subURI?: string;
subJsonURI?: string;
subJsonEnable?: boolean;
pageSize?: number;
remarkModel?: string;
datepicker?: string;
ipLimitEnable?: boolean;
}
const TRACKED_PROTOCOLS = [
Protocols.VMESS,
Protocols.VLESS,
@@ -33,40 +56,98 @@ const TRACKED_PROTOCOLS = [
Protocols.HYSTERIA,
];
async function fetchSlimInbounds(): Promise<unknown[]> {
const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true }) as ApiMsg<unknown[]>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbounds');
return Array.isArray(msg.obj) ? msg.obj : [];
}
async function fetchOnlineClients(): Promise<string[]> {
const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg<string[]>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
return Array.isArray(msg.obj) ? msg.obj : [];
}
async function fetchLastOnlineMap(): Promise<Record<string, number>> {
const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true }) as ApiMsg<Record<string, number>>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
return (msg.obj && typeof msg.obj === 'object') ? msg.obj : {};
}
async function fetchDefaultSettings(): Promise<DefaultsPayload> {
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg<DefaultsPayload>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
return (msg.obj as DefaultsPayload) || {};
}
export function useInbounds() {
const [fetched, setFetched] = useState(false);
const refreshingRef = useRef(false);
const queryClient = useQueryClient();
const slimQuery = useQuery({
queryKey: keys.inbounds.slim(),
queryFn: fetchSlimInbounds,
staleTime: Infinity,
});
const onlinesQuery = useQuery({
queryKey: keys.clients.onlines(),
queryFn: fetchOnlineClients,
staleTime: Infinity,
});
const lastOnlineQuery = useQuery({
queryKey: keys.clients.lastOnline(),
queryFn: fetchLastOnlineMap,
staleTime: Infinity,
});
const defaultsQuery = useQuery({
queryKey: keys.settings.defaults(),
queryFn: fetchDefaultSettings,
staleTime: Infinity,
});
const defaults = defaultsQuery.data ?? {};
const expireDiff = (defaults.expireDiff ?? 0) * 86400000;
const trafficDiff = (defaults.trafficDiff ?? 0) * 1073741824;
const tgBotEnable = !!defaults.tgBotEnable;
const ipLimitEnable = !!defaults.ipLimitEnable;
const pageSize = defaults.pageSize ?? 0;
const remarkModel = defaults.remarkModel || '-ieo';
const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
const subSettings: SubSettings = useMemo(() => ({
enable: !!defaults.subEnable,
subTitle: defaults.subTitle || '',
subURI: defaults.subURI || '',
subJsonURI: defaults.subJsonURI || '',
subJsonEnable: !!defaults.subJsonEnable,
}), [defaults.subEnable, defaults.subTitle, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable]);
useEffect(() => {
if (defaults.datepicker) setDatepicker(datepicker);
}, [datepicker, defaults.datepicker]);
const expireDiffRef = useRef(expireDiff);
expireDiffRef.current = expireDiff;
const trafficDiffRef = useRef(trafficDiff);
trafficDiffRef.current = trafficDiff;
// dbInbounds mirrors the slim query data wrapped as DBInbound instances, but
// stays mutable so the WS-driven applyClientStatsEvent / applyTrafficEvent
// can merge per-row updates without invalidating the entire query.
const [dbInbounds, setDbInbounds] = useState<DBInboundInstance[]>([]);
const dbInboundsRef = useRef<DBInboundInstance[]>([]);
dbInboundsRef.current = dbInbounds;
const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
const [statsVersion, setStatsVersion] = useState(0);
const [onlineClients, setOnlineClients] = useState<string[]>([]);
const onlineClientsRef = useRef<string[]>([]);
onlineClientsRef.current = onlineClients;
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
const [statsVersion, setStatsVersion] = useState(0);
const [expireDiff, setExpireDiff] = useState(0);
const expireDiffRef = useRef(0);
expireDiffRef.current = expireDiff;
const [trafficDiff, setTrafficDiff] = useState(0);
const trafficDiffRef = useRef(0);
trafficDiffRef.current = trafficDiff;
const [subSettings, setSubSettings] = useState<SubSettings>({
enable: false,
subTitle: '',
subURI: '',
subJsonURI: '',
subJsonEnable: false,
});
const [remarkModel, setRemarkModel] = useState('-ieo');
const [datepicker, setDatepickerState] = useState('gregorian');
const [tgBotEnable, setTgBotEnable] = useState(false);
const [ipLimitEnable, setIpLimitEnable] = useState(false);
const [pageSize, setPageSize] = useState(0);
const rollupClients = useCallback(
(dbInbound: DBInboundInstance, inbound: { clients?: { email?: string; enable?: boolean; comment?: string }[] }): ClientRollup => {
@@ -130,27 +211,6 @@ export function useInbounds() {
[],
);
const setInbounds = useCallback(
(rows: unknown[]) => {
const next: DBInboundInstance[] = [];
const counts: Record<number, ClientRollup> = {};
for (const row of rows as { protocol: string; id: number }[]) {
const dbInbound = new DBInbound(row) as DBInboundInstance;
const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean } }).toInbound();
next.push(dbInbound);
if (TRACKED_PROTOCOLS.includes(row.protocol)) {
if ((dbInbound as unknown as { isSS: boolean }).isSS && !parsed.isSSMultiUser) continue;
counts[row.id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
}
}
dbInboundsRef.current = next;
setDbInbounds(next);
setClientCount(counts);
setFetched(true);
},
[rollupClients],
);
const rebuildClientCount = useCallback(() => {
const counts: Record<number, ClientRollup> = {};
for (const dbInbound of dbInboundsRef.current) {
@@ -164,57 +224,46 @@ export function useInbounds() {
setClientCount(counts);
}, [rollupClients]);
const fetchOnlineUsers = useCallback(async () => {
const msg = await HttpUtil.post('/panel/api/clients/onlines');
if (msg?.success) {
const list = (msg.obj || []) as string[];
onlineClientsRef.current = list;
setOnlineClients(list);
// Seed dbInbounds + clientCount from the slim query. Runs on first fetch and
// again every time the query refetches (e.g. invalidate from WS bridge).
useEffect(() => {
if (!slimQuery.data) return;
const next: DBInboundInstance[] = [];
const counts: Record<number, ClientRollup> = {};
for (const row of slimQuery.data as { protocol: string; id: number }[]) {
const dbInbound = new DBInbound(row) as DBInboundInstance;
const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean } }).toInbound();
next.push(dbInbound);
if (TRACKED_PROTOCOLS.includes(row.protocol)) {
if ((dbInbound as unknown as { isSS: boolean }).isSS && !parsed.isSSMultiUser) continue;
counts[row.id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
}
}
}, []);
dbInboundsRef.current = next;
setDbInbounds(next);
setClientCount(counts);
}, [slimQuery.data, rollupClients]);
const fetchLastOnlineMap = useCallback(async () => {
const msg = await HttpUtil.post('/panel/api/clients/lastOnline');
if (msg?.success && msg.obj) {
setLastOnlineMap(msg.obj as Record<string, number>);
useEffect(() => {
if (onlinesQuery.data) {
onlineClientsRef.current = onlinesQuery.data;
setOnlineClients(onlinesQuery.data);
}
}, []);
}, [onlinesQuery.data]);
const fetchDefaultSettings = useCallback(async () => {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (!msg?.success) return;
const s = (msg.obj || {}) as Record<string, unknown>;
setExpireDiff((s.expireDiff as number ?? 0) * 86400000);
setTrafficDiff((s.trafficDiff as number ?? 0) * 1073741824);
setTgBotEnable(!!s.tgBotEnable);
setSubSettings({
enable: !!s.subEnable,
subTitle: (s.subTitle as string) || '',
subURI: (s.subURI as string) || '',
subJsonURI: (s.subJsonURI as string) || '',
subJsonEnable: !!s.subJsonEnable,
});
setPageSize((s.pageSize as number) ?? 0);
setRemarkModel((s.remarkModel as string) || '-ieo');
const dp = ((s.datepicker as string) || 'gregorian') as 'gregorian' | 'jalalian';
setDatepickerState(dp);
setDatepicker(dp);
setIpLimitEnable(!!s.ipLimitEnable);
}, []);
useEffect(() => {
if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
}, [lastOnlineQuery.data]);
const fetched = slimQuery.data !== undefined && defaultsQuery.data !== undefined;
const refresh = useCallback(async () => {
if (refreshingRef.current) return;
refreshingRef.current = true;
try {
const msg = await HttpUtil.get('/panel/api/inbounds/list/slim');
if (!msg?.success) return;
await fetchLastOnlineMap();
await fetchOnlineUsers();
setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
} finally {
window.setTimeout(() => { refreshingRef.current = false; }, 500);
}
}, [fetchLastOnlineMap, fetchOnlineUsers, setInbounds]);
await Promise.all([
queryClient.invalidateQueries({ queryKey: keys.inbounds.slim() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
]);
}, [queryClient]);
// hydrateInbound fetches the full inbound (including settings.clients with
// uuid/password/flow/etc.) and swaps it into the cached list. Use this
@@ -313,25 +362,6 @@ export function useInbounds() {
[rebuildClientCount],
);
const applyInvalidate = useCallback(
(payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { type?: string };
if (p.type === 'inbounds') {
refresh();
}
},
[refresh],
);
const applyInboundsEvent = useCallback(
(payload: unknown) => {
if (!Array.isArray(payload)) return;
setInbounds(payload);
},
[setInbounds],
);
const totals = useMemo(() => {
let up = 0;
let down = 0;
@@ -361,10 +391,7 @@ export function useInbounds() {
pageSize,
refresh,
hydrateInbound,
fetchDefaultSettings,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
applyInboundsEvent,
};
}

View File

@@ -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]);
@@ -72,7 +72,6 @@ export default function IndexPage() {
});
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
const [showIp, setShowIp] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
@@ -158,7 +157,7 @@ export default function IndexPage() {
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} />
<AppSidebar />
<Layout className="content-shell">
<Layout.Content className="content-area">

View File

@@ -13,7 +13,7 @@ import {
Switch,
message,
} from 'antd';
import type { NodeRecord } from '@/hooks/useNodes';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import './NodeFormModal.css';
type Mode = 'add' | 'edit';

View File

@@ -28,7 +28,7 @@ import {
} from '@ant-design/icons';
import NodeHistoryPanel from './NodeHistoryPanel';
import type { NodeRecord } from '@/hooks/useNodes';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import './NodeList.css';
interface NodeListProps {

View File

@@ -10,9 +10,9 @@ import {
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useNodes } from '@/hooks/useNodes';
import type { NodeRecord } from '@/hooks/useNodes';
import { useWebSocket } from '@/hooks/useWebSocket';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { useNodeMutations } from '@/api/queries/useNodeMutations';
import AppSidebar from '@/components/AppSidebar';
import CustomStatistic from '@/components/CustomStatistic';
import NodeList from './NodeList';
@@ -21,9 +21,6 @@ import { setMessageInstance } from '@/utils/messageBus';
import '@/styles/page-cards.css';
import './NodesPage.css';
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
export default function NodesPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
@@ -32,21 +29,8 @@ export default function NodesPage() {
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const {
nodes,
loading,
fetched,
totals,
applyNodesEvent,
create,
update,
remove,
setEnable,
testConnection,
probe,
} = useNodes();
useWebSocket({ nodes: applyNodesEvent });
const { nodes, loading, fetched, totals } = useNodesQuery();
const { create, update, remove, setEnable, testConnection, probe } = useNodeMutations();
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
@@ -112,7 +96,7 @@ export default function NodesPage() {
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} />
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">

View File

@@ -28,7 +28,7 @@ import { HttpUtil, PromiseUtil } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useAllSetting } from '@/hooks/useAllSetting';
import { useAllSettings } from '@/api/queries/useAllSettings';
import AppSidebar from '@/components/AppSidebar';
import GeneralTab from './GeneralTab';
import SecurityTab from './SecurityTab';
@@ -42,8 +42,6 @@ interface ApiMsg {
success?: boolean;
}
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
function slugToKey(slug: string): string {
@@ -94,7 +92,7 @@ export default function SettingsPage() {
setSpinning,
saveDisabled,
saveAll,
} = useAllSetting();
} = useAllSettings();
const [entryHost, setEntryHost] = useState('');
const [entryPort, setEntryPort] = useState('');
@@ -270,7 +268,7 @@ export default function SettingsPage() {
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} />
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">

View File

@@ -31,7 +31,6 @@ import {
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useWebSocket } from '@/hooks/useWebSocket';
import { useXraySetting } from '@/hooks/useXraySetting';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import AppSidebar from '@/components/AppSidebar';
@@ -89,7 +88,6 @@ export default function XrayPage() {
testingAll,
fetchAll,
resetOutboundsTraffic,
applyOutboundsEvent,
testOutbound,
testAllOutbounds,
saveAll,
@@ -97,8 +95,6 @@ export default function XrayPage() {
restartXray,
} = xs;
useWebSocket({ outbounds: applyOutboundsEvent as never });
const [modal, modalContextHolder] = Modal.useModal();
const [warpOpen, setWarpOpen] = useState(false);
const [nordOpen, setNordOpen] = useState(false);
@@ -140,9 +136,6 @@ export default function XrayPage() {
const warpExist = !!templateSettings?.outbounds?.find((o) => o?.tag === 'warp');
const nordExist = !!templateSettings?.outbounds?.find((o) => o?.tag?.startsWith?.('nord-'));
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
async function onTestOutbound(idx: number, mode: string) {
const outbound = templateSettings?.outbounds?.[idx];
if (outbound) await testOutbound(idx, outbound, mode);
@@ -259,7 +252,7 @@ export default function XrayPage() {
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} />
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">

View 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,
},
},
});

42
frontend/src/routes.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { lazy, Suspense } from 'react';
import { createBrowserRouter, type RouteObject } from 'react-router-dom';
import PanelLayout from '@/layouts/PanelLayout';
const IndexPage = lazy(() => import('@/pages/index/IndexPage'));
const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage'));
const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage'));
const NodesPage = lazy(() => import('@/pages/nodes/NodesPage'));
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
function withSuspense(node: React.ReactNode) {
return <Suspense fallback={null}>{node}</Suspense>;
}
const routes: RouteObject[] = [
{
path: '/',
element: <PanelLayout />,
children: [
{ index: true, element: withSuspense(<IndexPage />) },
{ path: 'inbounds', element: withSuspense(<InboundsPage />) },
{ path: 'clients', element: withSuspense(<ClientsPage />) },
{ path: 'nodes', element: withSuspense(<NodesPage />) },
{ path: 'settings', element: withSuspense(<SettingsPage />) },
{ path: 'xray', element: withSuspense(<XrayPage />) },
{ path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
],
},
];
function computeBasename() {
const raw = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '/';
const trimmed = raw.replace(/\/+$/, '');
return `${trimmed}/panel`;
}
export const router = createBrowserRouter(routes, {
basename: computeBasename(),
});

View File

@@ -22,22 +22,7 @@ function resolveDBPath() {
return '/etc/x-ui/x-ui.db';
}
const BASE_MIGRATED_ROUTES = {
'panel': '/index.html',
'panel/': '/index.html',
'panel/settings': '/settings.html',
'panel/settings/': '/settings.html',
'panel/inbounds': '/inbounds.html',
'panel/inbounds/': '/inbounds.html',
'panel/clients': '/clients.html',
'panel/clients/': '/clients.html',
'panel/xray': '/xray.html',
'panel/xray/': '/xray.html',
'panel/nodes': '/nodes.html',
'panel/nodes/': '/nodes.html',
'panel/api-docs': '/api-docs.html',
'panel/api-docs/': '/api-docs.html',
};
const PANEL_API_PREFIXES = ['panel/api/', 'panel/setting/', 'panel/xray/', 'panel/csrf-token'];
let cachedBasePath = '/';
@@ -101,7 +86,14 @@ function bypassMigratedRoute(req) {
if (url.startsWith(basePath)) {
const stripped = url.slice(basePath.length);
if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped];
for (const prefix of PANEL_API_PREFIXES) {
if (stripped === prefix.replace(/\/$/, '') || stripped.startsWith(prefix)) {
return undefined;
}
}
if (stripped === 'panel' || stripped === 'panel/' || stripped.startsWith('panel/')) {
return '/index.html';
}
}
return undefined;
}
@@ -172,12 +164,6 @@ export default defineConfig({
input: {
index: path.resolve(__dirname, 'index.html'),
login: path.resolve(__dirname, 'login.html'),
settings: path.resolve(__dirname, 'settings.html'),
inbounds: path.resolve(__dirname, 'inbounds.html'),
clients: path.resolve(__dirname, 'clients.html'),
xray: path.resolve(__dirname, 'xray.html'),
nodes: path.resolve(__dirname, 'nodes.html'),
apiDocs: path.resolve(__dirname, 'api-docs.html'),
subpage: path.resolve(__dirname, 'subpage.html'),
},
output: {
@@ -210,6 +196,13 @@ export default defineConfig({
) return 'vendor-codemirror';
if (id.includes('/node_modules/persian-calendar-suite/')) return 'vendor-jalali';
if (id.includes('/node_modules/otpauth/')) return 'vendor-otpauth';
if (id.includes('/node_modules/@tanstack/')) return 'vendor-tanstack';
if (id.includes('/node_modules/react-router')) return 'vendor-router';
if (
id.includes('/node_modules/swagger-ui-react/')
|| id.includes('/node_modules/swagger-ui/')
|| id.includes('/node_modules/swagger-client/')
) return 'vendor-swagger';
if (id.includes('dayjs')) return 'vendor-dayjs';
if (id.includes('axios')) return 'vendor-axios';
return 'vendor';

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Xray Config</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/xray.tsx"></script>
</body>
</html>

View File

@@ -23,6 +23,21 @@ func SetDistFS(fs embed.FS) {
var distPageBuildTime = time.Now()
// ServeOpenAPISpec returns the generated OpenAPI 3.0 description of the
// panel API. Postman / Insomnia / openapi-generator consume this URL
// directly; the in-panel Swagger UI page also fetches it. The spec is
// produced at frontend build time by scripts/build-openapi.mjs and
// embedded into the binary via the dist FS.
func ServeOpenAPISpec(c *gin.Context) {
body, err := distFS.ReadFile("dist/openapi.json")
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
return
}
c.Header("Cache-Control", "public, max-age=300")
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
func serveDistPage(c *gin.Context, name string) {
body, err := distFS.ReadFile("dist/" + name)
if err != nil {

View File

@@ -26,18 +26,23 @@ func NewXUIController(g *gin.RouterGroup) *XUIController {
}
// initRouter sets up the main panel routes and initializes sub-controllers.
//
// The HTML routes all hand the same single-page-app shell (index.html) to the
// browser; React Router takes over and renders the correct page from the URL.
// The /panel/api, /panel/setting, /panel/xray sub-routers register POST/JSON
// endpoints on different paths and stay untouched by the shell handler.
func (a *XUIController) initRouter(g *gin.RouterGroup) {
g = g.Group("/panel")
g.Use(a.checkLogin)
g.Use(middleware.CSRFMiddleware())
g.GET("/", a.index)
g.GET("/inbounds", a.inbounds)
g.GET("/clients", a.clients)
g.GET("/nodes", a.nodes)
g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings)
g.GET("/api-docs", a.apiDocs)
g.GET("/", a.panelSPA)
g.GET("/inbounds", a.panelSPA)
g.GET("/clients", a.panelSPA)
g.GET("/nodes", a.panelSPA)
g.GET("/settings", a.panelSPA)
g.GET("/xray", a.panelSPA)
g.GET("/api-docs", a.panelSPA)
// SPA pages built by Vite don't have a server-rendered <meta name="csrf-token">,
// so they fetch the session token via this endpoint at startup and replay it
@@ -48,45 +53,13 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
a.xraySettingController = NewXraySettingController(g)
}
// The main panel's HTML routes serve the pre-built SPA pages from distFS,
// instead of rendering the legacy Go templates. Each handler is a
// thin wrapper around serveDistPage so the basePath injection +
// no-cache headers stay centralised.
// index renders the main panel index page.
func (a *XUIController) index(c *gin.Context) {
// panelSPA serves the React SPA shell. Every GET under /panel/ that isn't an
// API endpoint returns the same index.html — React Router reads the URL and
// mounts the matching page on the client.
func (a *XUIController) panelSPA(c *gin.Context) {
serveDistPage(c, "index.html")
}
// inbounds renders the inbounds management page.
func (a *XUIController) inbounds(c *gin.Context) {
serveDistPage(c, "inbounds.html")
}
func (a *XUIController) clients(c *gin.Context) {
serveDistPage(c, "clients.html")
}
// nodes renders the multi-panel nodes management page.
func (a *XUIController) nodes(c *gin.Context) {
serveDistPage(c, "nodes.html")
}
// settings renders the settings management page.
func (a *XUIController) settings(c *gin.Context) {
serveDistPage(c, "settings.html")
}
// xraySettings renders the Xray settings page.
func (a *XUIController) xraySettings(c *gin.Context) {
serveDistPage(c, "xray.html")
}
// apiDocs renders the in-panel API documentation page.
func (a *XUIController) apiDocs(c *gin.Context) {
serveDistPage(c, "api-docs.html")
}
// csrfToken returns the session CSRF token to authenticated SPA clients.
// The endpoint is GET (a safe method) so it bypasses CSRFMiddleware itself,
// but checkLogin still gates the response — anonymous callers get 401/redirect.

View File

@@ -227,6 +227,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.index = controller.NewIndexController(g)
s.panel = controller.NewXUIController(g)
g.GET("/panel/api/openapi.json", controller.ServeOpenAPISpec)
s.api = controller.NewAPIController(g, s.customGeoService)
// Initialize WebSocket hub