docs(api): align OpenAPI with runtime contracts (#6409)

Document the cookie-authenticated WebSocket upgrade and its emitted envelopes without exporting pseudo-paths. Align REST response schemas, paged-client filters, and subscription HEAD operations with their runtime implementations, then regenerate frontend and docs artifacts.
This commit is contained in:
Gleb Gudkov
2026-09-04 16:23:41 +03:00
committed by GitHub
parent 3b5273b1d6
commit ed6bc1d898
20 changed files with 4609 additions and 535 deletions
@@ -44,6 +44,29 @@
overflow: hidden;
}
.api-docs-page .websocket-events {
margin-bottom: 16px;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px;
}
.api-docs-page .websocket-events h2 {
margin-top: 0;
}
.api-docs-page .websocket-events pre {
margin: 0;
padding: 12px;
max-height: 360px;
overflow: auto;
color: var(--sw-text, inherit);
background: var(--sw-bg-code, var(--ant-color-fill-quaternary));
border-radius: 6px;
font-size: 12px;
}
.api-docs-page .swagger-ui {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
+25 -1
View File
@@ -1,15 +1,18 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ConfigProvider, Layout } from 'antd';
import { Card, Col, ConfigProvider, Layout, Row, Typography } from 'antd';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/layouts/AppSidebar';
import { EXAMPLES } from '@/generated/examples';
import { buildWebSocketEvents } from './websocket-events';
import './ApiDocsPage.css';
const basePath = window.X_UI_BASE_PATH || '';
const openApiUrl = `${basePath}panel/api/openapi.json`;
const websocketEvents = buildWebSocketEvents(EXAMPLES);
export default function ApiDocsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
@@ -29,6 +32,27 @@ export default function ApiDocsPage() {
<Layout className="content-shell">
<Layout.Content className="content-area">
<section className="websocket-events" aria-labelledby="websocket-events-title">
<Typography.Title id="websocket-events-title" level={2}>
WebSocket events
</Typography.Title>
<Typography.Paragraph>
After the cookie-authenticated <Typography.Text code>GET /ws</Typography.Text>{' '}
upgrade, every server message uses{' '}
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The time value
is Unix milliseconds.
</Typography.Paragraph>
<Row gutter={[12, 12]}>
{websocketEvents.map((event) => (
<Col key={event.type} xs={24} sm={12} xl={8}>
<Card size="small" title={<Typography.Text code>{event.type}</Typography.Text>}>
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
<pre>{JSON.stringify(event.example, null, 2)}</pre>
</Card>
</Col>
))}
</Row>
</section>
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
<SwaggerUI
url={openApiUrl}
+147 -50
View File
@@ -1,4 +1,4 @@
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'WS';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';
export type ParamLocation =
| 'path'
| 'query'
@@ -28,6 +28,7 @@ export interface EndpointParam {
defaultValue?: string | number | boolean;
minLength?: number;
pattern?: string;
enum?: readonly (string | number | boolean)[];
}
export interface Endpoint {
@@ -45,6 +46,9 @@ export interface Endpoint {
bodyRequiredOneOf?: string[];
responseSchema?: string;
responseSchemaArray?: boolean;
responseObjectSchema?: Record<string, unknown>;
responses?: Record<string, Record<string, unknown>>;
security?: readonly Record<string, readonly string[]>[];
}
export interface SubscriptionHeader {
@@ -172,6 +176,12 @@ const subBalancerBodyParams: EndpointParam[] = [
},
];
const subscriptionHeadResponses = {
'200': { description: 'Subscription is available. Headers match GET; no response body.' },
'404': { description: 'No enabled client matches the subscription ID.' },
'500': { description: 'Subscription generation failed.' },
};
export const sections: readonly Section[] = [
{
id: 'authentication',
@@ -556,7 +566,7 @@ export const sections: readonly Section[] = [
method: 'GET',
path: '/panel/api/server/getNewUUID',
summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.',
response: '{\n "success": true,\n "obj": "550e8400-e29b-41d4-a716-446655440000"\n}',
responseSchema: 'NewUUIDResponse',
},
{
method: 'GET',
@@ -584,18 +594,14 @@ export const sections: readonly Section[] = [
{
method: 'GET',
path: '/panel/api/server/getNewmldsa65',
summary:
'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.',
response:
'{\n "success": true,\n "obj": {\n "privateKey": "mdsa65priv...",\n "publicKey": "mdsa65pub...",\n "seed": "random-seed..."\n }\n}',
summary: 'Generate a new ML-DSA-65 keypair. Returns {seed, verify}.',
responseSchema: 'MLDSA65Response',
},
{
method: 'GET',
path: '/panel/api/server/getNewmlkem768',
summary:
'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.',
response:
'{\n "success": true,\n "obj": {\n "clientKey": "mlkem768-client...",\n "serverKey": "mlkem768-server..."\n }\n}',
summary: 'Generate a new ML-KEM-768 keypair. Returns {seed, client}.',
responseSchema: 'MLKEM768Response',
},
{
method: 'GET',
@@ -703,8 +709,9 @@ export const sections: readonly Section[] = [
},
],
body: 'level=info&syslog=false',
responseObjectSchema: { type: 'array', items: { type: 'string' } },
response:
'{\n "success": true,\n "obj": "2025/01/01 12:00:00 [INFO] Server started\\n2025/01/01 12:00:01 [INFO] Xray is running"\n}',
'{\n "success": true,\n "obj": [\n "2025/01/01 12:00:00 [INFO] Server started",\n "2025/01/01 12:00:01 [INFO] Xray is running"\n ]\n}',
},
{
method: 'POST',
@@ -742,8 +749,8 @@ export const sections: readonly Section[] = [
},
],
body: 'filter=error&showDirect=false&showBlocked=true&showProxy=true',
response:
'{\n "success": true,\n "obj": "2025/01/01 12:00:00 rejected vless proxy example.com reason: no valid user\\n2025/01/01 12:00:01 direct freedom ok"\n}',
responseSchema: 'LogEntry',
responseSchemaArray: true,
},
{
method: 'POST',
@@ -966,41 +973,132 @@ export const sections: readonly Section[] = [
in: 'query',
type: 'number',
desc: '1-indexed page number. Defaults to 1.',
optional: true,
defaultValue: 1,
},
{
name: 'pageSize',
in: 'query',
type: 'number',
desc: 'Rows per page. Defaults to 25, capped at 200.',
optional: true,
defaultValue: 25,
},
{
name: 'search',
in: 'query',
type: 'string',
desc: 'Case-insensitive substring match on email / subId / comment.',
desc: 'Case-insensitive substring match on email, subId, comment, UUID, password, auth or Telegram ID.',
optional: true,
},
{
name: 'filter',
in: 'query',
type: 'string',
desc: 'Status bucket: online | active | deactive | depleted | expiring.',
desc: 'CSV status buckets: online, active, deactive, depleted or expiring. Values are ORed.',
optional: true,
},
{
name: 'protocol',
in: 'query',
type: 'string',
desc: 'Match clients attached to at least one inbound of this protocol (vless, vmess, trojan, shadowsocks, ...).',
desc: 'CSV inbound protocols: vmess, vless, trojan, shadowsocks, wireguard, hysteria, http, mixed, tunnel, tun, mtproto or amneziawg. Values are ORed.',
optional: true,
},
{
name: 'inbound',
in: 'query',
type: 'string',
desc: 'CSV positive inbound IDs. Values are ORed; invalid or non-positive IDs are ignored.',
optional: true,
},
{
name: 'sort',
in: 'query',
type: 'string',
desc: 'Sort key: enable | email | inboundIds | traffic | remaining | expiryTime.',
desc: 'Sort key. An omitted or unknown value falls back to client ID ascending.',
optional: true,
enum: [
'enable',
'email',
'inboundIds',
'traffic',
'remaining',
'expiryTime',
'createdAt',
'updatedAt',
'lastOnline',
],
},
{
name: 'order',
in: 'query',
type: 'string',
desc: 'Sort direction. Only descend selects descending order; otherwise ascending.',
optional: true,
enum: ['ascend', 'descend'],
},
{
name: 'expiryFrom',
in: 'query',
type: 'number',
desc: 'Inclusive minimum expiry time in Unix milliseconds. Zero or negative means unset.',
optional: true,
},
{
name: 'expiryTo',
in: 'query',
type: 'number',
desc: 'Inclusive maximum expiry time in Unix milliseconds. Zero or negative means unbounded.',
optional: true,
},
{
name: 'usageFrom',
in: 'query',
type: 'number',
desc: 'Inclusive minimum combined upload and download usage in bytes. Zero means unset.',
optional: true,
},
{
name: 'usageTo',
in: 'query',
type: 'number',
desc: 'Inclusive maximum combined upload and download usage in bytes. Zero means unbounded.',
optional: true,
},
{
name: 'autoRenew',
in: 'query',
type: 'string',
desc: 'on selects clients with an interval or calendar-day reset; off selects clients without either.',
optional: true,
enum: ['on', 'off'],
},
{
name: 'hasTgId',
in: 'query',
type: 'string',
desc: 'yes selects clients with a non-zero Telegram ID; no selects clients without one.',
optional: true,
enum: ['yes', 'no'],
},
{
name: 'hasComment',
in: 'query',
type: 'string',
desc: 'yes selects clients with a non-blank comment; no selects clients without one.',
optional: true,
enum: ['yes', 'no'],
},
{
name: 'group',
in: 'query',
type: 'string',
desc: 'CSV group names, matched case-insensitively after trimming. Values are ORed.',
optional: true,
},
{ name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
],
response:
'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "limitHwid": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "onlineCount": 1,\n "depletedCount": 0,\n "expiringCount": 0,\n "deactiveCount": 150,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": ["bob@example.com"]\n }\n }\n}',
responseSchema: 'ClientPageResponse',
},
{
method: 'GET',
@@ -2503,6 +2601,14 @@ export const sections: readonly Section[] = [
},
],
},
{
method: 'HEAD',
path: '/{subPath}:subid',
summary:
'Return the same status and subscription metadata headers as GET without a response body.',
params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }],
responses: subscriptionHeadResponses,
},
{
method: 'GET',
path: '/{jsonPath}:subid',
@@ -2510,6 +2616,14 @@ export const sections: readonly Section[] = [
'Return subscription as a JSON array of proxy configs (one per enabled client). Only when JSON subscription is enabled in settings. The path prefix is configured by subJsonPath.',
params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }],
},
{
method: 'HEAD',
path: '/{jsonPath}:subid',
summary:
'Return the JSON subscription status and metadata headers without a body. Registered only when JSON subscriptions are enabled.',
params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }],
responses: subscriptionHeadResponses,
},
{
method: 'GET',
path: '/{clashPath}:subid',
@@ -2517,6 +2631,14 @@ export const sections: readonly Section[] = [
'Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. The path prefix is configured by subClashPath.',
params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }],
},
{
method: 'HEAD',
path: '/{clashPath}:subid',
summary:
'Return the Clash subscription status and metadata headers without a body. Registered only when Clash subscriptions are enabled.',
params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }],
responses: subscriptionHeadResponses,
},
],
},
@@ -2531,36 +2653,11 @@ export const sections: readonly Section[] = [
path: '/ws',
summary:
'Upgrade an HTTP connection to a WebSocket. Requires an authenticated session cookie (Bearer token auth is not supported here). Returns 101 Switching Protocols on success. The server then pushes JSON messages described below.',
},
{
method: 'WS',
path: '→ type: status',
summary:
'Server health snapshot pushed every 2 seconds. Contains CPU, memory, swap, disk, network IO, load, and Xray state — same shape as <code>GET /panel/api/server/status</code>.',
response:
'{\n "type": "status",\n "data": { "cpu": 12.5, "mem": { "current": 2147483648, "total": 8589934592 }, "xray": { "state": "running" } }\n}',
},
{
method: 'WS',
path: '→ type: xrayState',
summary:
'Xray process state change. Fired when Xray starts, stops, or encounters an error.',
response: '{\n "type": "xrayState",\n "data": "running"\n}',
},
{
method: 'WS',
path: '→ type: notification',
summary:
'In-panel toast notification. Fired on Xray stop/restart, DB import, panel restart, etc.',
response:
'{\n "type": "notification",\n "title": "Xray service restarted",\n "body": "Xray has been restarted successfully",\n "severity": "success"\n}',
},
{
method: 'WS',
path: '→ type: invalidate',
summary:
'Instructs the UI to re-fetch a resource. Fired when another admin session modifies data (e.g. toggling inbound enable).',
response: '{\n "type": "invalidate",\n "resource": "inbounds"\n}',
responses: {
'101': { description: 'Switching Protocols. WebSocket messages use WebSocketEnvelope.' },
'401': { description: 'No authenticated panel session cookie.' },
},
security: [{ cookieAuth: [] }],
},
],
},
@@ -0,0 +1,325 @@
export interface WebSocketEventDoc {
type: string;
summary: string;
payloadSchema: Record<string, unknown>;
example: {
type: string;
payload: unknown;
time: number;
};
}
const eventTypes = [
'status',
'traffic',
'client_stats',
'inbounds',
'outbounds',
'nodes',
'notification',
'xray_state',
'invalidate',
] as const;
const timestamp = 1735689600000;
const int64 = { type: 'integer', format: 'int64' };
const stringArray = { type: 'array', items: { type: 'string' } };
const stringArrayMap = { type: 'object', additionalProperties: stringArray };
const timestampMap = { type: 'object', additionalProperties: int64 };
const currentTotal = {
type: 'object',
required: ['current', 'total'],
properties: { current: int64, total: int64 },
};
const statusPayloadSchema = {
type: 'object',
required: [
'cpu',
'cpuCores',
'logicalPro',
'cpuSpeedMhz',
'mem',
'swap',
'disk',
'diskIO',
'diskTraffic',
'xray',
'amneziawg',
'panelVersion',
'panelGuid',
'uptime',
'loads',
'tcpCount',
'udpCount',
'netIO',
'netTraffic',
'publicIP',
'appStats',
],
properties: {
cpu: { type: 'number' },
cpuCores: { type: 'integer' },
logicalPro: { type: 'integer' },
cpuSpeedMhz: { type: 'number' },
mem: currentTotal,
swap: currentTotal,
disk: currentTotal,
diskIO: {
type: 'object',
required: ['read', 'write'],
properties: { read: int64, write: int64 },
},
diskTraffic: {
type: 'object',
required: ['read', 'write'],
properties: { read: int64, write: int64 },
},
xray: {
type: 'object',
required: ['state', 'errorMsg', 'version'],
properties: {
state: { type: 'string', enum: ['running', 'stop', 'error'] },
errorMsg: { type: 'string' },
version: { type: 'string' },
},
},
amneziawg: {
type: 'object',
required: ['configured', 'running'],
properties: { configured: { type: 'boolean' }, running: { type: 'boolean' } },
},
panelVersion: { type: 'string' },
panelGuid: { type: 'string' },
uptime: int64,
loads: { type: 'array', nullable: true, items: { type: 'number' } },
tcpCount: { type: 'integer' },
udpCount: { type: 'integer' },
netIO: {
type: 'object',
required: ['up', 'down', 'pktUp', 'pktDown'],
properties: { up: int64, down: int64, pktUp: int64, pktDown: int64 },
},
netTraffic: {
type: 'object',
required: ['sent', 'recv', 'pktSent', 'pktRecv'],
properties: { sent: int64, recv: int64, pktSent: int64, pktRecv: int64 },
},
publicIP: {
type: 'object',
required: ['ipv4', 'ipv6'],
properties: { ipv4: { type: 'string' }, ipv6: { type: 'string' } },
},
appStats: {
type: 'object',
required: ['threads', 'mem', 'uptime'],
properties: { threads: { type: 'integer' }, mem: int64, uptime: int64 },
},
},
};
const trafficPayloadSchema = {
type: 'object',
required: ['onlineClients', 'onlineByGuid', 'activeInbounds', 'lastOnlineMap'],
properties: {
traffics: { type: 'array', items: { $ref: '#/components/schemas/Traffic' } },
clientTraffics: { type: 'array', items: { $ref: '#/components/schemas/ClientTraffic' } },
nodeTraffics: {
type: 'array',
nullable: true,
items: { $ref: '#/components/schemas/Traffic' },
},
onlineClients: stringArray,
onlineByGuid: stringArrayMap,
activeInbounds: stringArrayMap,
lastOnlineMap: timestampMap,
},
oneOf: [{ required: ['traffics', 'clientTraffics'] }, { required: ['nodeTraffics'] }],
};
const clientStatsPayloadSchema = {
type: 'object',
required: ['snapshot'],
properties: {
snapshot: { type: 'boolean' },
clients: { type: 'array', items: { $ref: '#/components/schemas/ClientTraffic' } },
inbounds: {
type: 'array',
items: { $ref: '#/components/schemas/InboundTrafficSummary' },
},
},
anyOf: [{ required: ['clients'] }, { required: ['inbounds'] }],
};
export const websocketEnvelopeSchema = {
type: 'object',
required: ['type', 'payload', 'time'],
properties: {
type: { type: 'string', enum: eventTypes },
payload: { description: 'Shape is selected by type; see x-websocket-events on GET /ws.' },
time: {
type: 'integer',
format: 'int64',
description: 'Server emission time in Unix milliseconds.',
},
},
};
export function buildWebSocketEvents(
examples: Record<string, unknown>,
): readonly WebSocketEventDoc[] {
return [
{
type: 'status',
summary:
'Server health snapshot pushed every two seconds; same payload as server/status obj.',
payloadSchema: statusPayloadSchema,
example: {
type: 'status',
payload: {
cpu: 12.5,
cpuCores: 4,
logicalPro: 8,
cpuSpeedMhz: 3200,
mem: { current: 2147483648, total: 8589934592 },
swap: { current: 0, total: 2147483648 },
disk: { current: 53687091200, total: 107374182400 },
diskIO: { read: 1048576, write: 2097152 },
diskTraffic: { read: 4096, write: 8192 },
xray: { state: 'running', errorMsg: '', version: '25.10.31' },
amneziawg: { configured: false, running: false },
panelVersion: 'v3.x.x',
panelGuid: 'panel-guid',
uptime: 86400,
loads: [0.1, 0.2, 0.3],
tcpCount: 24,
udpCount: 8,
netIO: { up: 1048576, down: 2097152, pktUp: 100, pktDown: 200 },
netTraffic: { sent: 4096, recv: 8192, pktSent: 10, pktRecv: 20 },
publicIP: { ipv4: '192.0.2.1', ipv6: '2001:db8::1' },
appStats: { threads: 16, mem: 67108864, uptime: 3600 },
},
time: timestamp,
},
},
{
type: 'traffic',
summary:
'Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.',
payloadSchema: trafficPayloadSchema,
example: {
type: 'traffic',
payload: {
traffics: [examples.Traffic],
clientTraffics: [examples.ClientTraffic],
onlineClients: ['alice@example.com'],
onlineByGuid: { 'panel-guid': ['alice@example.com'] },
activeInbounds: { 'panel-guid': ['inbound-443'] },
lastOnlineMap: { 'alice@example.com': timestamp },
},
time: timestamp,
},
},
{
type: 'client_stats',
summary:
'Absolute client counters and/or inbound summaries; snapshot says whether clients is complete or only recently active rows.',
payloadSchema: clientStatsPayloadSchema,
example: {
type: 'client_stats',
payload: {
snapshot: true,
clients: [examples.ClientTraffic],
inbounds: [examples.InboundTrafficSummary],
},
time: timestamp,
},
},
{
type: 'inbounds',
summary: 'Full inbound list after an inbound mutation, unless invalidate is used at scale.',
payloadSchema: { type: 'array', items: { $ref: '#/components/schemas/Inbound' } },
example: { type: 'inbounds', payload: [examples.Inbound], time: timestamp },
},
{
type: 'outbounds',
summary: 'Current outbound traffic rows after the periodic traffic collection.',
payloadSchema: {
type: 'array',
items: { $ref: '#/components/schemas/OutboundTraffics' },
},
example: { type: 'outbounds', payload: [examples.OutboundTraffics], time: timestamp },
},
{
type: 'nodes',
summary: 'Current node tree after the heartbeat probe cycle.',
payloadSchema: { type: 'array', items: { $ref: '#/components/schemas/NodeView' } },
example: { type: 'nodes', payload: [examples.NodeView], time: timestamp },
},
{
type: 'notification',
summary: 'An in-panel notification emitted by server actions.',
payloadSchema: {
type: 'object',
required: ['title', 'message', 'level'],
properties: {
title: { type: 'string' },
message: { type: 'string' },
level: { type: 'string', enum: ['success', 'warning'] },
},
},
example: {
type: 'notification',
payload: {
title: 'Xray service restarted',
message: 'Xray service has been restarted successfully',
level: 'success',
},
time: timestamp,
},
},
{
type: 'xray_state',
summary: 'Xray process state change after a stop, restart or error.',
payloadSchema: {
type: 'object',
required: ['state', 'errorMsg'],
properties: {
state: { type: 'string', enum: ['running', 'stop', 'error'] },
errorMsg: { type: 'string' },
},
},
example: {
type: 'xray_state',
payload: { state: 'running', errorMsg: '' },
time: timestamp,
},
},
{
type: 'invalidate',
summary:
'Requests a REST re-fetch. clients is an invalidate payload type, not a top-level event.',
payloadSchema: {
type: 'object',
required: ['type'],
properties: {
type: {
type: 'string',
enum: [
'status',
'traffic',
'client_stats',
'inbounds',
'outbounds',
'nodes',
'notification',
'xray_state',
'clients',
],
},
},
},
example: { type: 'invalidate', payload: { type: 'inbounds' }, time: timestamp },
},
];
}