소스 검색

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.
Gleb Gudkov 6 시간 전
부모
커밋
ed6bc1d898

+ 8 - 12
docs/content/docs/en/reference/api/server.mdx

@@ -92,13 +92,11 @@ _openapi:
       title: Generate a new X25519 keypair for Reality.
       url: '#generate-a-new-x25519-keypair-for-reality'
     - depth: 2
-      title: Generate a new ML-DSA-65 keypair (post-quantum signature). Returns
-        {privateKey, publicKey, seed}.
-      url: '#generate-a-new-ml-dsa-65-keypair-post-quantum-signature-returns-privatekey-publickey-seed'
+      title: Generate a new ML-DSA-65 keypair. Returns {seed, verify}.
+      url: '#generate-a-new-ml-dsa-65-keypair-returns-seed-verify'
     - depth: 2
-      title: Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey,
-        serverKey}.
-      url: '#generate-a-new-ml-kem-768-keypair-post-quantum-kem-returns-clientkey-serverkey'
+      title: Generate a new ML-KEM-768 keypair. Returns {seed, client}.
+      url: '#generate-a-new-ml-kem-768-keypair-returns-seed-client'
     - depth: 2
       title: Generate VLESS encryption auth options. Returns an auths array each with
         id, label, encryption, and decryption fields.
@@ -248,12 +246,10 @@ _openapi:
         id: read-only-summaries-guid-parentguid-name-address-status-versions-of-the-nodes-this-panel-manages-a-parent-panel-calls-it-on-a-node-via-the-node-api-token-to-surface-transitive-sub-nodes-in-a-chained-topology-counts-are-computed-by-the-parent-not-returned-here
       - content: Generate a new X25519 keypair for Reality.
         id: generate-a-new-x25519-keypair-for-reality
-      - content: Generate a new ML-DSA-65 keypair (post-quantum signature). Returns
-          {privateKey, publicKey, seed}.
-        id: generate-a-new-ml-dsa-65-keypair-post-quantum-signature-returns-privatekey-publickey-seed
-      - content: Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns
-          {clientKey, serverKey}.
-        id: generate-a-new-ml-kem-768-keypair-post-quantum-kem-returns-clientkey-serverkey
+      - content: Generate a new ML-DSA-65 keypair. Returns {seed, verify}.
+        id: generate-a-new-ml-dsa-65-keypair-returns-seed-verify
+      - content: Generate a new ML-KEM-768 keypair. Returns {seed, client}.
+        id: generate-a-new-ml-kem-768-keypair-returns-seed-client
       - content: Generate VLESS encryption auth options. Returns an auths array each
           with id, label, encryption, and decryption fields.
         id: generate-vless-encryption-auth-options-returns-an-auths-array-each-with-id-label-encryption-and-decryption-fields

+ 22 - 1
docs/content/docs/en/reference/api/subscription-server.mdx

@@ -19,16 +19,28 @@ _openapi:
         online status; no links) for live polling. The path prefix is configured
         by subPath.'
       url: '#return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-the-path-prefix-is-configured-by-subpath'
+    - depth: 2
+      title: Return the same status and subscription metadata headers as GET without a
+        response body.
+      url: '#return-the-same-status-and-subscription-metadata-headers-as-get-without-a-response-body'
     - depth: 2
       title: 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.
       url: '#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'
+    - depth: 2
+      title: Return the JSON subscription status and metadata headers without a body.
+        Registered only when JSON subscriptions are enabled.
+      url: '#return-the-json-subscription-status-and-metadata-headers-without-a-body-registered-only-when-json-subscriptions-are-enabled'
     - depth: 2
       title: 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.
       url: '#return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subclashpath'
+    - depth: 2
+      title: Return the Clash subscription status and metadata headers without a body.
+        Registered only when Clash subscriptions are enabled.
+      url: '#return-the-clash-subscription-status-and-metadata-headers-without-a-body-registered-only-when-clash-subscriptions-are-enabled'
   structuredData:
     headings:
       - content: 'Return base64-encoded subscription links for all enabled clients
@@ -38,14 +50,23 @@ _openapi:
           online status; no links) for live polling. The path prefix is
           configured by subPath.'
         id: return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-the-path-prefix-is-configured-by-subpath
+      - content: Return the same status and subscription metadata headers as GET without
+          a response body.
+        id: return-the-same-status-and-subscription-metadata-headers-as-get-without-a-response-body
       - content: 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.
         id: 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
+      - content: Return the JSON subscription status and metadata headers without a
+          body. Registered only when JSON subscriptions are enabled.
+        id: return-the-json-subscription-status-and-metadata-headers-without-a-body-registered-only-when-json-subscriptions-are-enabled
       - content: 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.
         id: return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subclashpath
+      - content: Return the Clash subscription status and metadata headers without a
+          body. Registered only when Clash subscriptions are enabled.
+        id: return-the-clash-subscription-status-and-metadata-headers-without-a-body-registered-only-when-clash-subscriptions-are-enabled
     contents: []
 ---
 
@@ -58,7 +79,7 @@ export default function Layout(props) {
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/{subPath}{subid}","method":"get"},{"path":"/{jsonPath}{subid}","method":"get"},{"path":"/{clashPath}{subid}","method":"get"}]} showTitle />
+      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/{subPath}{subid}","method":"get"},{"path":"/{subPath}{subid}","method":"head"},{"path":"/{jsonPath}{subid}","method":"get"},{"path":"/{jsonPath}{subid}","method":"head"},{"path":"/{clashPath}{subid}","method":"get"},{"path":"/{clashPath}{subid}","method":"head"}]} showTitle />
     </>
   );
 }

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 705 - 107
docs/public/openapi.json


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 705 - 107
frontend/public/openapi.json


+ 32 - 16
frontend/scripts/build-openapi.mjs

@@ -4,9 +4,15 @@ import { join, dirname } from 'node:path';
 import { fileURLToPath, pathToFileURL } from 'node:url';
 
 import { sections } from '../src/pages/api-docs/endpoints.ts';
+import {
+  buildWebSocketEvents,
+  websocketEnvelopeSchema,
+} from '../src/pages/api-docs/websocket-events.ts';
 import { EXAMPLES } from '../src/generated/examples.ts';
 import { SCHEMAS } from '../src/generated/schemas.ts';
 
+const websocketEvents = buildWebSocketEvents(EXAMPLES);
+
 const __dirname = dirname(fileURLToPath(import.meta.url));
 const outPath = join(__dirname, '..', 'public', 'openapi.json');
 
@@ -65,6 +71,7 @@ function schemaFromParam(p) {
   if (p.defaultValue !== undefined) schema.default = p.defaultValue;
   if (p.minLength !== undefined) schema.minLength = p.minLength;
   if (p.pattern !== undefined) schema.pattern = p.pattern;
+  if (p.enum !== undefined) schema.enum = [...p.enum];
   return schema;
 }
 
@@ -175,8 +182,7 @@ function buildOperation(ep, tag) {
             const branchProperties = { ...properties };
             for (const other of ep.bodyRequiredOneOf) {
               if (other === name || !branchProperties[other]) continue;
-              const { pattern: _pattern, minLength: _minLength, ...rest } =
-                branchProperties[other];
+              const { pattern: _pattern, minLength: _minLength, ...rest } = branchProperties[other];
               branchProperties[other] = rest;
             }
             return {
@@ -222,6 +228,10 @@ function buildOperation(ep, tag) {
   const responses = {};
   let successExample = tryParseJson(ep.response);
   let objSchema = {};
+  if (ep.responseObjectSchema && ep.responseSchema) {
+    throw new Error(`${ep.method} ${ep.path}: responseObjectSchema cannot use responseSchema`);
+  }
+  if (ep.responseObjectSchema) objSchema = ep.responseObjectSchema;
   if (ep.responseSchema) {
     const obj = EXAMPLES[ep.responseSchema];
     if (obj === undefined) {
@@ -240,22 +250,26 @@ function buildOperation(ep, tag) {
       successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
     }
   }
-  responses['200'] = {
-    description: 'Successful response',
-    content: {
-      'application/json': {
-        schema: {
-          type: 'object',
-          properties: {
-            success: { type: 'boolean' },
-            msg: { type: 'string' },
-            obj: objSchema,
+  if (ep.responses) {
+    Object.assign(responses, ep.responses);
+  } else {
+    responses['200'] = {
+      description: 'Successful response',
+      content: {
+        'application/json': {
+          schema: {
+            type: 'object',
+            properties: {
+              success: { type: 'boolean' },
+              msg: { type: 'string' },
+              obj: objSchema,
+            },
           },
+          ...(successExample !== undefined ? { example: successExample } : {}),
         },
-        ...(successExample !== undefined ? { example: successExample } : {}),
       },
-    },
-  };
+    };
+  }
 
   const errExample = tryParseJson(ep.errorResponse);
   if (errExample !== undefined || ep.errorStatus) {
@@ -278,6 +292,7 @@ function buildOperation(ep, tag) {
   }
 
   op.responses = responses;
+  if (ep.security !== undefined) op.security = ep.security;
   return op;
 }
 
@@ -291,6 +306,7 @@ export function buildSpec() {
       paths[openApiPath][ep.method.toLowerCase()] = buildOperation(ep, tag);
     }
   }
+  paths['/ws'].get['x-websocket-events'] = websocketEvents;
 
   const tags = sections.map((s) => ({
     name: s.title,
@@ -308,7 +324,7 @@ export function buildSpec() {
     servers: [{ url: '/', description: 'Current panel (basePath aware)' }],
     components: {
       securitySchemes: SECURITY_SCHEMES,
-      schemas: SCHEMAS,
+      schemas: { ...SCHEMAS, WebSocketEnvelope: websocketEnvelopeSchema },
     },
     security: [{ bearerAuth: [] }, { cookieAuth: [] }],
     tags,

+ 120 - 0
frontend/src/generated/examples.ts

@@ -300,6 +300,55 @@ export const EXAMPLES: Record<string, unknown> = {
     "flowOverride": "",
     "inboundId": 0
   },
+  "ClientPageResponse": {
+    "filtered": 47,
+    "groups": [
+      "staff",
+      "trial"
+    ],
+    "items": [
+      {
+        "comment": "Primary device",
+        "createdAt": 1735000000000,
+        "email": "[email protected]",
+        "enable": true,
+        "expiryTime": 1735689600000,
+        "group": "staff",
+        "inboundIds": [
+          3,
+          5
+        ],
+        "limitHwid": 0,
+        "limitIp": 0,
+        "reset": 0,
+        "resetDay": 0,
+        "resetMax": 0,
+        "subId": "abcd1234",
+        "totalGB": 53687091200,
+        "traffic": null,
+        "updatedAt": 1735100000000
+      }
+    ],
+    "page": 1,
+    "pageSize": 25,
+    "summary": {
+      "active": 1850,
+      "deactive": [
+        "[email protected]"
+      ],
+      "deactiveCount": 150,
+      "depleted": [],
+      "depletedCount": 0,
+      "expiring": [],
+      "expiringCount": 0,
+      "online": [
+        "[email protected]"
+      ],
+      "onlineCount": 1,
+      "total": 2000
+    },
+    "total": 2000
+  },
   "ClientRecord": {
     "adTag": "",
     "allowedIPs": "",
@@ -337,6 +386,27 @@ export const EXAMPLES: Record<string, unknown> = {
   "ClientReverse": {
     "tag": ""
   },
+  "ClientSlim": {
+    "comment": "Primary device",
+    "createdAt": 1735000000000,
+    "email": "[email protected]",
+    "enable": true,
+    "expiryTime": 1735689600000,
+    "group": "staff",
+    "inboundIds": [
+      3,
+      5
+    ],
+    "limitHwid": 0,
+    "limitIp": 0,
+    "reset": 0,
+    "resetDay": 0,
+    "resetMax": 0,
+    "subId": "abcd1234",
+    "totalGB": 53687091200,
+    "traffic": null,
+    "updatedAt": 1735100000000
+  },
   "ClientTraffic": {
     "down": 2097152,
     "email": "user1",
@@ -355,6 +425,22 @@ export const EXAMPLES: Record<string, unknown> = {
     "up": 1048576,
     "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
   },
+  "ClientsSummary": {
+    "active": 1850,
+    "deactive": [
+      "[email protected]"
+    ],
+    "deactiveCount": 150,
+    "depleted": [],
+    "depletedCount": 0,
+    "expiring": [],
+    "expiringCount": 0,
+    "online": [
+      "[email protected]"
+    ],
+    "onlineCount": 1,
+    "total": 2000
+  },
   "FallbackParentInfo": {
     "masterId": 0,
     "path": ""
@@ -586,11 +672,38 @@ export const EXAMPLES: Record<string, unknown> = {
     "wgMtu": 0,
     "wgPublicKey": ""
   },
+  "InboundTrafficSummary": {
+    "down": 2097152,
+    "enable": true,
+    "id": 1,
+    "total": 10737418240,
+    "up": 1048576
+  },
+  "LogEntry": {
+    "DateTime": "2025-01-01T12:00:00Z",
+    "Email": "[email protected]",
+    "Event": 0,
+    "FromAddress": "192.0.2.10:54321",
+    "Inbound": "inbound-443",
+    "Outbound": "direct",
+    "ToAddress": "example.com:443"
+  },
+  "MLDSA65Response": {
+    "seed": "mldsa65-seed",
+    "verify": "mldsa65-verify"
+  },
+  "MLKEM768Response": {
+    "client": "mlkem768-client",
+    "seed": "mlkem768-seed"
+  },
   "Msg": {
     "msg": "",
     "obj": null,
     "success": false
   },
+  "NewUUIDResponse": {
+    "uuid": "550e8400-e29b-41d4-a716-446655440000"
+  },
   "Node": {
     "activeCount": 23,
     "address": "node1.example.com",
@@ -819,6 +932,13 @@ export const EXAMPLES: Record<string, unknown> = {
     "strategy": "random",
     "updatedAt": 1710000000000
   },
+  "Traffic": {
+    "Down": 2097152,
+    "IsInbound": true,
+    "IsOutbound": false,
+    "Tag": "inbound-443",
+    "Up": 1048576
+  },
   "User": {
     "id": 0,
     "password": "",

+ 382 - 0
frontend/src/generated/schemas.ts

@@ -1245,6 +1245,56 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "ClientPageResponse": {
+    "description": "ClientPageResponse is the shape returned by ListPaged. `Total` is the\nrow count in the DB; `Filtered` is the count after Search/Filter/Protocol\nwere applied, before pagination. The page contains at most PageSize items.\nSummary is computed across the full DB row set so dashboard counters\non the clients page stay stable as the user paginates/filters.",
+    "properties": {
+      "filtered": {
+        "example": 47,
+        "type": "integer"
+      },
+      "groups": {
+        "example": [
+          "staff",
+          "trial"
+        ],
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "items": {
+        "items": {
+          "$ref": "#/components/schemas/ClientSlim"
+        },
+        "type": "array"
+      },
+      "page": {
+        "example": 1,
+        "type": "integer"
+      },
+      "pageSize": {
+        "example": 25,
+        "type": "integer"
+      },
+      "summary": {
+        "$ref": "#/components/schemas/ClientsSummary"
+      },
+      "total": {
+        "example": 2000,
+        "type": "integer"
+      }
+    },
+    "required": [
+      "filtered",
+      "groups",
+      "items",
+      "page",
+      "pageSize",
+      "summary",
+      "total"
+    ],
+    "type": "object"
+  },
   "ClientRecord": {
     "properties": {
       "adTag": {
@@ -1394,6 +1444,105 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "ClientSlim": {
+    "description": "ClientSlim is the row-shape used by the clients page. It drops fields the\ntable never reads (UUID, password, auth, flow, security, reverse, tgId)\nso the list payload stays compact even when the panel manages thousands\nof clients. Modals that need the full record still call /get/:email.",
+    "properties": {
+      "comment": {
+        "example": "Primary device",
+        "type": "string"
+      },
+      "createdAt": {
+        "example": 1735000000000,
+        "format": "int64",
+        "type": "integer"
+      },
+      "email": {
+        "example": "[email protected]",
+        "type": "string"
+      },
+      "enable": {
+        "example": true,
+        "type": "boolean"
+      },
+      "expiryTime": {
+        "example": 1735689600000,
+        "format": "int64",
+        "type": "integer"
+      },
+      "group": {
+        "example": "staff",
+        "type": "string"
+      },
+      "inboundIds": {
+        "example": [
+          3,
+          5
+        ],
+        "items": {
+          "type": "integer"
+        },
+        "type": "array"
+      },
+      "limitHwid": {
+        "example": 0,
+        "type": "integer"
+      },
+      "limitIp": {
+        "example": 0,
+        "type": "integer"
+      },
+      "reset": {
+        "example": 0,
+        "type": "integer"
+      },
+      "resetDay": {
+        "example": 0,
+        "type": "integer"
+      },
+      "resetMax": {
+        "example": 0,
+        "type": "integer"
+      },
+      "subId": {
+        "example": "abcd1234",
+        "type": "string"
+      },
+      "totalGB": {
+        "example": 53687091200,
+        "format": "int64",
+        "type": "integer"
+      },
+      "traffic": {
+        "allOf": [
+          {
+            "$ref": "#/components/schemas/ClientTraffic"
+          }
+        ],
+        "nullable": true
+      },
+      "updatedAt": {
+        "example": 1735100000000,
+        "format": "int64",
+        "type": "integer"
+      }
+    },
+    "required": [
+      "createdAt",
+      "email",
+      "enable",
+      "expiryTime",
+      "inboundIds",
+      "limitHwid",
+      "limitIp",
+      "reset",
+      "resetDay",
+      "resetMax",
+      "subId",
+      "totalGB",
+      "updatedAt"
+    ],
+    "type": "object"
+  },
   "ClientTraffic": {
     "description": "ClientTraffic represents traffic statistics and limits for a specific client.\nIt tracks upload/download usage, expiry times, and online status for inbound clients.",
     "properties": {
@@ -1491,6 +1640,80 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "ClientsSummary": {
+    "description": "ClientsSummary collects per-bucket counts plus the matching email lists so\nthe clients page can render the dashboard stat cards and their hover\npopovers without shipping the full client array. The counters are exact;\nthe lists stop at clientSummaryEmailCap entries and only back the popovers.",
+    "properties": {
+      "active": {
+        "example": 1850,
+        "type": "integer"
+      },
+      "deactive": {
+        "example": [
+          "[email protected]"
+        ],
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "deactiveCount": {
+        "example": 150,
+        "type": "integer"
+      },
+      "depleted": {
+        "example": [],
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "depletedCount": {
+        "example": 0,
+        "type": "integer"
+      },
+      "expiring": {
+        "example": [],
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "expiringCount": {
+        "example": 0,
+        "type": "integer"
+      },
+      "online": {
+        "example": [
+          "[email protected]"
+        ],
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "onlineCount": {
+        "example": 1,
+        "type": "integer"
+      },
+      "total": {
+        "example": 2000,
+        "type": "integer"
+      }
+    },
+    "required": [
+      "active",
+      "deactive",
+      "deactiveCount",
+      "depleted",
+      "depletedCount",
+      "expiring",
+      "expiringCount",
+      "online",
+      "onlineCount",
+      "total"
+    ],
+    "type": "object"
+  },
   "FallbackParentInfo": {
     "description": "FallbackParentInfo carries everything the frontend needs to rewrite a\nchild inbound's client link: where to connect (the master's address\nand port) and which path matched on the master's fallbacks array.\nThe frontend already has the master inbound in its dbInbounds list,\nso we only ship identifiers + the match path here.",
     "properties": {
@@ -2337,6 +2560,118 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "InboundTrafficSummary": {
+    "properties": {
+      "down": {
+        "example": 2097152,
+        "format": "int64",
+        "type": "integer"
+      },
+      "enable": {
+        "example": true,
+        "type": "boolean"
+      },
+      "id": {
+        "example": 1,
+        "type": "integer"
+      },
+      "total": {
+        "example": 10737418240,
+        "format": "int64",
+        "type": "integer"
+      },
+      "up": {
+        "example": 1048576,
+        "format": "int64",
+        "type": "integer"
+      }
+    },
+    "required": [
+      "down",
+      "enable",
+      "id",
+      "total",
+      "up"
+    ],
+    "type": "object"
+  },
+  "LogEntry": {
+    "properties": {
+      "DateTime": {
+        "example": "2025-01-01T12:00:00Z",
+        "format": "date-time",
+        "type": "string"
+      },
+      "Email": {
+        "example": "[email protected]",
+        "type": "string"
+      },
+      "Event": {
+        "example": 0,
+        "type": "integer"
+      },
+      "FromAddress": {
+        "example": "192.0.2.10:54321",
+        "type": "string"
+      },
+      "Inbound": {
+        "example": "inbound-443",
+        "type": "string"
+      },
+      "Outbound": {
+        "example": "direct",
+        "type": "string"
+      },
+      "ToAddress": {
+        "example": "example.com:443",
+        "type": "string"
+      }
+    },
+    "required": [
+      "DateTime",
+      "Email",
+      "Event",
+      "FromAddress",
+      "Inbound",
+      "Outbound",
+      "ToAddress"
+    ],
+    "type": "object"
+  },
+  "MLDSA65Response": {
+    "properties": {
+      "seed": {
+        "example": "mldsa65-seed",
+        "type": "string"
+      },
+      "verify": {
+        "example": "mldsa65-verify",
+        "type": "string"
+      }
+    },
+    "required": [
+      "seed",
+      "verify"
+    ],
+    "type": "object"
+  },
+  "MLKEM768Response": {
+    "properties": {
+      "client": {
+        "example": "mlkem768-client",
+        "type": "string"
+      },
+      "seed": {
+        "example": "mlkem768-seed",
+        "type": "string"
+      }
+    },
+    "required": [
+      "client",
+      "seed"
+    ],
+    "type": "object"
+  },
   "Msg": {
     "properties": {
       "msg": {
@@ -2354,6 +2689,18 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "NewUUIDResponse": {
+    "properties": {
+      "uuid": {
+        "example": "550e8400-e29b-41d4-a716-446655440000",
+        "type": "string"
+      }
+    },
+    "required": [
+      "uuid"
+    ],
+    "type": "object"
+  },
   "Node": {
     "description": "Node represents a remote 3x-ui panel registered with the central panel.\nThe central panel polls each node's existing /panel/api/server/status\nendpoint over HTTP using the per-node ApiToken to populate the runtime\nstatus fields below.",
     "properties": {
@@ -3409,6 +3756,41 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "Traffic": {
+    "description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.",
+    "properties": {
+      "Down": {
+        "example": 2097152,
+        "format": "int64",
+        "type": "integer"
+      },
+      "IsInbound": {
+        "example": true,
+        "type": "boolean"
+      },
+      "IsOutbound": {
+        "example": false,
+        "type": "boolean"
+      },
+      "Tag": {
+        "example": "inbound-443",
+        "type": "string"
+      },
+      "Up": {
+        "example": 1048576,
+        "format": "int64",
+        "type": "integer"
+      }
+    },
+    "required": [
+      "Down",
+      "IsInbound",
+      "IsOutbound",
+      "Tag",
+      "Up"
+    ],
+    "type": "object"
+  },
   "User": {
     "description": "User represents a user account in the 3x-ui panel.",
     "properties": {

+ 82 - 0
frontend/src/generated/types.ts

@@ -298,6 +298,16 @@ export interface ClientInbound {
   inboundId: number;
 }
 
+export interface ClientPageResponse {
+  filtered: number;
+  groups: string[];
+  items: ClientSlim[];
+  page: number;
+  pageSize: number;
+  summary: ClientsSummary;
+  total: number;
+}
+
 export interface ClientRecord {
   adTag: string;
   allowedIPs: string;
@@ -337,6 +347,25 @@ export interface ClientReverse {
   tag: string;
 }
 
+export interface ClientSlim {
+  comment?: string;
+  createdAt: number;
+  email: string;
+  enable: boolean;
+  expiryTime: number;
+  group?: string;
+  inboundIds: number[];
+  limitHwid: number;
+  limitIp: number;
+  reset: number;
+  resetDay: number;
+  resetMax: number;
+  subId: string;
+  totalGB: number;
+  traffic?: ClientTraffic | null;
+  updatedAt: number;
+}
+
 export interface ClientTraffic {
   down: number;
   email: string;
@@ -356,6 +385,19 @@ export interface ClientTraffic {
   uuid: string;
 }
 
+export interface ClientsSummary {
+  active: number;
+  deactive: string[];
+  deactiveCount: number;
+  depleted: string[];
+  depletedCount: number;
+  expiring: string[];
+  expiringCount: number;
+  online: string[];
+  onlineCount: number;
+  total: number;
+}
+
 export interface FallbackParentInfo {
   masterId: number;
   path?: string;
@@ -541,12 +583,44 @@ export interface InboundOption {
   wgPublicKey?: string;
 }
 
+export interface InboundTrafficSummary {
+  down: number;
+  enable: boolean;
+  id: number;
+  total: number;
+  up: number;
+}
+
+export interface LogEntry {
+  DateTime: string;
+  Email: string;
+  Event: number;
+  FromAddress: string;
+  Inbound: string;
+  Outbound: string;
+  ToAddress: string;
+}
+
+export interface MLDSA65Response {
+  seed: string;
+  verify: string;
+}
+
+export interface MLKEM768Response {
+  client: string;
+  seed: string;
+}
+
 export interface Msg {
   msg: string;
   obj: unknown;
   success: boolean;
 }
 
+export interface NewUUIDResponse {
+  uuid: string;
+}
+
 export interface Node {
   activeCount: number;
   address: string;
@@ -775,6 +849,14 @@ export interface SubBalancer {
   updatedAt: number;
 }
 
+export interface Traffic {
+  Down: number;
+  IsInbound: boolean;
+  IsOutbound: boolean;
+  Tag: string;
+  Up: number;
+}
+
 export interface User {
   id: number;
   password: string;

+ 91 - 0
frontend/src/generated/zod.ts

@@ -321,6 +321,17 @@ export const ClientInboundSchema = z.object({
 });
 export type ClientInbound = z.infer<typeof ClientInboundSchema>;
 
+export const ClientPageResponseSchema = z.object({
+  filtered: z.number().int(),
+  groups: z.array(z.string()),
+  items: z.array(z.lazy(() => ClientSlimSchema)),
+  page: z.number().int(),
+  pageSize: z.number().int(),
+  summary: z.lazy(() => ClientsSummarySchema),
+  total: z.number().int(),
+});
+export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
+
 export const ClientRecordSchema = z.object({
   adTag: z.string(),
   allowedIPs: z.string(),
@@ -362,6 +373,26 @@ export const ClientReverseSchema = z.object({
 });
 export type ClientReverse = z.infer<typeof ClientReverseSchema>;
 
+export const ClientSlimSchema = z.object({
+  comment: z.string().optional(),
+  createdAt: z.number().int(),
+  email: z.string(),
+  enable: z.boolean(),
+  expiryTime: z.number().int(),
+  group: z.string().optional(),
+  inboundIds: z.array(z.number().int()),
+  limitHwid: z.number().int(),
+  limitIp: z.number().int(),
+  reset: z.number().int(),
+  resetDay: z.number().int(),
+  resetMax: z.number().int(),
+  subId: z.string(),
+  totalGB: z.number().int(),
+  traffic: z.lazy(() => ClientTrafficSchema).nullable().optional(),
+  updatedAt: z.number().int(),
+});
+export type ClientSlim = z.infer<typeof ClientSlimSchema>;
+
 export const ClientTrafficSchema = z.object({
   down: z.number().int(),
   email: z.string(),
@@ -382,6 +413,20 @@ export const ClientTrafficSchema = z.object({
 });
 export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
 
+export const ClientsSummarySchema = z.object({
+  active: z.number().int(),
+  deactive: z.array(z.string()),
+  deactiveCount: z.number().int(),
+  depleted: z.array(z.string()),
+  depletedCount: z.number().int(),
+  expiring: z.array(z.string()),
+  expiringCount: z.number().int(),
+  online: z.array(z.string()),
+  onlineCount: z.number().int(),
+  total: z.number().int(),
+});
+export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
+
 export const FallbackParentInfoSchema = z.object({
   masterId: z.number().int(),
   path: z.string().optional(),
@@ -581,6 +626,38 @@ export const InboundOptionSchema = z.object({
 });
 export type InboundOption = z.infer<typeof InboundOptionSchema>;
 
+export const InboundTrafficSummarySchema = z.object({
+  down: z.number().int(),
+  enable: z.boolean(),
+  id: z.number().int(),
+  total: z.number().int(),
+  up: z.number().int(),
+});
+export type InboundTrafficSummary = z.infer<typeof InboundTrafficSummarySchema>;
+
+export const LogEntrySchema = z.object({
+  DateTime: z.string(),
+  Email: z.string(),
+  Event: z.number().int(),
+  FromAddress: z.string(),
+  Inbound: z.string(),
+  Outbound: z.string(),
+  ToAddress: z.string(),
+});
+export type LogEntry = z.infer<typeof LogEntrySchema>;
+
+export const MLDSA65ResponseSchema = z.object({
+  seed: z.string(),
+  verify: z.string(),
+});
+export type MLDSA65Response = z.infer<typeof MLDSA65ResponseSchema>;
+
+export const MLKEM768ResponseSchema = z.object({
+  client: z.string(),
+  seed: z.string(),
+});
+export type MLKEM768Response = z.infer<typeof MLKEM768ResponseSchema>;
+
 export const MsgSchema = z.object({
   msg: z.string(),
   obj: z.unknown(),
@@ -588,6 +665,11 @@ export const MsgSchema = z.object({
 });
 export type Msg = z.infer<typeof MsgSchema>;
 
+export const NewUUIDResponseSchema = z.object({
+  uuid: z.string(),
+});
+export type NewUUIDResponse = z.infer<typeof NewUUIDResponseSchema>;
+
 export const NodeSchema = z.object({
   activeCount: z.number().int(),
   address: z.string(),
@@ -827,6 +909,15 @@ export const SubBalancerSchema = z.object({
 });
 export type SubBalancer = z.infer<typeof SubBalancerSchema>;
 
+export const TrafficSchema = z.object({
+  Down: z.number().int(),
+  IsInbound: z.boolean(),
+  IsOutbound: z.boolean(),
+  Tag: z.string(),
+  Up: z.number().int(),
+});
+export type Traffic = z.infer<typeof TrafficSchema>;
+
 export const UserSchema = z.object({
   id: z.number().int(),
   password: z.string(),

+ 23 - 0
frontend/src/pages/api-docs/ApiDocsPage.css

@@ -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
frontend/src/pages/api-docs/ApiDocsPage.tsx

@@ -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
frontend/src/pages/api-docs/endpoints.ts

@@ -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": "[email protected]",\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": ["[email protected]"],\n      "depleted": [],\n      "expiring": [],\n      "deactive": ["[email protected]"]\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: [] }],
       },
     ],
   },

+ 325 - 0
frontend/src/pages/api-docs/websocket-events.ts

@@ -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: ['[email protected]'],
+          onlineByGuid: { 'panel-guid': ['[email protected]'] },
+          activeInbounds: { 'panel-guid': ['inbound-443'] },
+          lastOnlineMap: { '[email protected]': 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 },
+    },
+  ];
+}

+ 228 - 0
frontend/src/test/openapi-runtime-contracts.test.ts

@@ -0,0 +1,228 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildSpec } from '../../scripts/build-openapi.mjs';
+
+interface OpenApiSchema {
+  $ref?: string;
+  type?: string;
+  format?: string;
+  enum?: readonly string[];
+  required?: string[];
+  properties?: Record<string, OpenApiSchema>;
+  items?: OpenApiSchema;
+}
+
+interface OpenApiParameter {
+  name: string;
+  required: boolean;
+  description: string;
+  schema: OpenApiSchema;
+}
+
+interface WebSocketEventDoc {
+  type: string;
+  summary: string;
+  payloadSchema: OpenApiSchema;
+  example: { type: string; payload: unknown; time: number };
+}
+
+interface OpenApiOperation {
+  parameters?: OpenApiParameter[];
+  responses: Record<
+    string,
+    {
+      content?: Record<string, { schema: OpenApiSchema }>;
+    }
+  >;
+  security?: Record<string, never[]>[];
+  'x-websocket-events'?: WebSocketEventDoc[];
+}
+
+interface OpenApiSpec {
+  paths: Record<string, Record<string, OpenApiOperation>>;
+  components: { schemas: Record<string, OpenApiSchema> };
+}
+
+const spec = buildSpec() as unknown as OpenApiSpec;
+
+function operation(path: string, method: string): OpenApiOperation {
+  const op = spec.paths[path]?.[method];
+  if (!op) throw new Error(`${method.toUpperCase()} ${path} is missing`);
+  return op;
+}
+
+function responseObjectSchema(path: string, method = 'get'): OpenApiSchema {
+  const schema = operation(path, method).responses['200']?.content?.['application/json']?.schema;
+  const obj = schema?.properties?.obj;
+  if (!obj) throw new Error(`${method.toUpperCase()} ${path} has no response obj schema`);
+  return obj;
+}
+
+describe('generated OpenAPI runtime contracts', () => {
+  it('exports only valid OpenAPI paths and HTTP methods', () => {
+    const validMethods = new Set([
+      'get',
+      'put',
+      'post',
+      'delete',
+      'options',
+      'head',
+      'patch',
+      'trace',
+    ]);
+
+    for (const [path, pathItem] of Object.entries(spec.paths)) {
+      expect(path.startsWith('/'), path).toBe(true);
+      for (const method of Object.keys(pathItem)) {
+        expect(validMethods.has(method), `${method.toUpperCase()} ${path}`).toBe(true);
+      }
+    }
+  });
+
+  it('documents the WebSocket handshake and every emitted event', () => {
+    const ws = operation('/ws', 'get');
+    expect(Object.keys(ws.responses)).toEqual(['101', '401']);
+    expect(ws.security).toEqual([{ cookieAuth: [] }]);
+
+    const envelope = spec.components.schemas.WebSocketEnvelope;
+    expect(envelope.required).toEqual(['type', 'payload', 'time']);
+    expect(envelope.properties?.time).toMatchObject({ type: 'integer', format: 'int64' });
+
+    const events = ws['x-websocket-events'] ?? [];
+    expect(events.map((event) => event.type)).toEqual([
+      'status',
+      'traffic',
+      'client_stats',
+      'inbounds',
+      'outbounds',
+      'nodes',
+      'notification',
+      'xray_state',
+      'invalidate',
+    ]);
+    expect(events.map((event) => event.type)).not.toContain('clients');
+
+    for (const event of events) {
+      expect(Object.keys(event.example)).toEqual(['type', 'payload', 'time']);
+      expect(event.example.type).toBe(event.type);
+      expect(event.example.time).toEqual(expect.any(Number));
+    }
+
+    const byType = Object.fromEntries(events.map((event) => [event.type, event]));
+    expect(Object.keys(byType.notification.payloadSchema.properties ?? {})).toEqual([
+      'title',
+      'message',
+      'level',
+    ]);
+    expect(Object.keys(byType.xray_state.payloadSchema.properties ?? {})).toEqual([
+      'state',
+      'errorMsg',
+    ]);
+    expect(byType.invalidate.payloadSchema.properties?.type?.enum).toContain('clients');
+    expect(byType.xray_state.example.payload).toEqual({ state: 'running', errorMsg: '' });
+    expect(byType.invalidate.example.payload).toEqual({ type: 'inbounds' });
+    expect(byType.status.payloadSchema.properties?.loads).toMatchObject({
+      type: 'array',
+      nullable: true,
+    });
+  });
+
+  it('uses the runtime REST response schemas', () => {
+    expect(responseObjectSchema('/panel/api/server/logs/{count}', 'post')).toEqual({
+      type: 'array',
+      items: { type: 'string' },
+    });
+    expect(responseObjectSchema('/panel/api/server/xraylogs/{count}', 'post')).toEqual({
+      type: 'array',
+      items: { $ref: '#/components/schemas/LogEntry' },
+    });
+    expect(responseObjectSchema('/panel/api/server/getNewUUID')).toEqual({
+      $ref: '#/components/schemas/NewUUIDResponse',
+    });
+    expect(responseObjectSchema('/panel/api/server/getNewmldsa65')).toEqual({
+      $ref: '#/components/schemas/MLDSA65Response',
+    });
+    expect(responseObjectSchema('/panel/api/server/getNewmlkem768')).toEqual({
+      $ref: '#/components/schemas/MLKEM768Response',
+    });
+
+    const logEntryFields = Object.keys(spec.components.schemas.LogEntry.properties ?? {});
+    expect(logEntryFields).toHaveLength(7);
+    expect(logEntryFields).toEqual(
+      expect.arrayContaining([
+        'DateTime',
+        'FromAddress',
+        'ToAddress',
+        'Inbound',
+        'Outbound',
+        'Email',
+        'Event',
+      ]),
+    );
+    expect(spec.components.schemas.LogEntry.properties?.DateTime).toMatchObject({
+      type: 'string',
+      format: 'date-time',
+    });
+    expect(Object.keys(spec.components.schemas.NewUUIDResponse.properties ?? {})).toEqual(['uuid']);
+    expect(Object.keys(spec.components.schemas.MLDSA65Response.properties ?? {})).toEqual([
+      'seed',
+      'verify',
+    ]);
+    const mlkemFields = Object.keys(spec.components.schemas.MLKEM768Response.properties ?? {});
+    expect(mlkemFields).toHaveLength(2);
+    expect(mlkemFields).toEqual(expect.arrayContaining(['seed', 'client']));
+  });
+
+  it('documents every paged-client query and the groups response', () => {
+    const paged = operation('/panel/api/clients/list/paged', 'get');
+    expect(paged.parameters?.map((param) => param.name)).toEqual([
+      'page',
+      'pageSize',
+      'search',
+      'filter',
+      'protocol',
+      'inbound',
+      'sort',
+      'order',
+      'expiryFrom',
+      'expiryTo',
+      'usageFrom',
+      'usageTo',
+      'autoRenew',
+      'hasTgId',
+      'hasComment',
+      'group',
+    ]);
+    expect(paged.parameters?.every((param) => param.required === false)).toBe(true);
+
+    const params = Object.fromEntries((paged.parameters ?? []).map((param) => [param.name, param]));
+    for (const name of ['filter', 'protocol', 'inbound', 'group']) {
+      expect(params[name].description).toContain('CSV');
+    }
+    expect(params.sort.schema.enum).toEqual([
+      'enable',
+      'email',
+      'inboundIds',
+      'traffic',
+      'remaining',
+      'expiryTime',
+      'createdAt',
+      'updatedAt',
+      'lastOnline',
+    ]);
+
+    expect(responseObjectSchema('/panel/api/clients/list/paged')).toEqual({
+      $ref: '#/components/schemas/ClientPageResponse',
+    });
+    expect(spec.components.schemas.ClientPageResponse.properties?.groups).toMatchObject({
+      type: 'array',
+      items: { type: 'string' },
+    });
+  });
+
+  it('includes HEAD operations for every subscription variant', () => {
+    expect(operation('/{subPath}{subid}', 'head')).toBeDefined();
+    expect(operation('/{jsonPath}{subid}', 'head')).toBeDefined();
+    expect(operation('/{clashPath}{subid}', 'head')).toBeDefined();
+  });
+});

+ 1 - 1
internal/web/routes_contract_test.go

@@ -78,7 +78,7 @@ func documentedContractRoutes(t *testing.T) map[string]bool {
 		t.Fatalf("read endpoints.ts: %v", err)
 	}
 	text := string(source)
-	methodRe := regexp.MustCompile(`method:\s*'(GET|POST|PUT|DELETE|PATCH|WS)'`)
+	methodRe := regexp.MustCompile(`method:\s*'(GET|POST|PUT|DELETE|PATCH|HEAD|WS)'`)
 	pathRe := regexp.MustCompile(`path:\s*'([^']+)'`)
 	methods := methodRe.FindAllStringSubmatchIndex(text, -1)
 	if declared := strings.Count(text, "method: '"); len(methods) != declared {

+ 30 - 30
internal/web/service/client_paging.go

@@ -18,22 +18,22 @@ import (
 // so the list payload stays compact even when the panel manages thousands
 // of clients. Modals that need the full record still call /get/:email.
 type ClientSlim struct {
-	Email      string              `json:"email"`
-	SubID      string              `json:"subId"`
-	Enable     bool                `json:"enable"`
-	TotalGB    int64               `json:"totalGB"`
-	ExpiryTime int64               `json:"expiryTime"`
-	LimitIP    int                 `json:"limitIp"`
-	LimitHwid  int                 `json:"limitHwid"`
-	Reset      int                 `json:"reset"`
-	ResetDay   int                 `json:"resetDay"`
-	ResetMax   int                 `json:"resetMax"`
-	Group      string              `json:"group,omitempty"`
-	Comment    string              `json:"comment,omitempty"`
-	InboundIds []int               `json:"inboundIds"`
+	Email      string              `json:"email" example:"[email protected]"`
+	SubID      string              `json:"subId" example:"abcd1234"`
+	Enable     bool                `json:"enable" example:"true"`
+	TotalGB    int64               `json:"totalGB" example:"53687091200"`
+	ExpiryTime int64               `json:"expiryTime" example:"1735689600000"`
+	LimitIP    int                 `json:"limitIp" example:"0"`
+	LimitHwid  int                 `json:"limitHwid" example:"0"`
+	Reset      int                 `json:"reset" example:"0"`
+	ResetDay   int                 `json:"resetDay" example:"0"`
+	ResetMax   int                 `json:"resetMax" example:"0"`
+	Group      string              `json:"group,omitempty" example:"staff"`
+	Comment    string              `json:"comment,omitempty" example:"Primary device"`
+	InboundIds []int               `json:"inboundIds" example:"[3,5]"`
 	Traffic    *xray.ClientTraffic `json:"traffic,omitempty"`
-	CreatedAt  int64               `json:"createdAt"`
-	UpdatedAt  int64               `json:"updatedAt"`
+	CreatedAt  int64               `json:"createdAt" example:"1735000000000"`
+	UpdatedAt  int64               `json:"updatedAt" example:"1735100000000"`
 }
 
 // ClientPageParams are the query params accepted by /panel/api/clients/list/paged.
@@ -70,12 +70,12 @@ type ClientPageParams struct {
 // on the clients page stay stable as the user paginates/filters.
 type ClientPageResponse struct {
 	Items    []ClientSlim   `json:"items"`
-	Total    int            `json:"total"`
-	Filtered int            `json:"filtered"`
-	Page     int            `json:"page"`
-	PageSize int            `json:"pageSize"`
+	Total    int            `json:"total" example:"2000"`
+	Filtered int            `json:"filtered" example:"47"`
+	Page     int            `json:"page" example:"1"`
+	PageSize int            `json:"pageSize" example:"25"`
 	Summary  ClientsSummary `json:"summary"`
-	Groups   []string       `json:"groups"`
+	Groups   []string       `json:"groups" example:"[\"staff\",\"trial\"]"`
 }
 
 // ClientsSummary collects per-bucket counts plus the matching email lists so
@@ -83,16 +83,16 @@ type ClientPageResponse struct {
 // popovers without shipping the full client array. The counters are exact;
 // the lists stop at clientSummaryEmailCap entries and only back the popovers.
 type ClientsSummary struct {
-	Total         int      `json:"total"`
-	Active        int      `json:"active"`
-	OnlineCount   int      `json:"onlineCount"`
-	DepletedCount int      `json:"depletedCount"`
-	ExpiringCount int      `json:"expiringCount"`
-	DeactiveCount int      `json:"deactiveCount"`
-	Online        []string `json:"online"`
-	Depleted      []string `json:"depleted"`
-	Expiring      []string `json:"expiring"`
-	Deactive      []string `json:"deactive"`
+	Total         int      `json:"total" example:"2000"`
+	Active        int      `json:"active" example:"1850"`
+	OnlineCount   int      `json:"onlineCount" example:"1"`
+	DepletedCount int      `json:"depletedCount" example:"0"`
+	ExpiringCount int      `json:"expiringCount" example:"0"`
+	DeactiveCount int      `json:"deactiveCount" example:"150"`
+	Online        []string `json:"online" example:"[\"[email protected]\"]"`
+	Depleted      []string `json:"depleted" example:"[]"`
+	Expiring      []string `json:"expiring" example:"[]"`
+	Deactive      []string `json:"deactive" example:"[\"[email protected]\"]"`
 }
 
 const (

+ 5 - 5
internal/web/service/inbound_traffic.go

@@ -1148,11 +1148,11 @@ func (s *InboundService) CountClientTraffics() (int64, error) {
 }
 
 type InboundTrafficSummary struct {
-	Id     int   `json:"id"`
-	Up     int64 `json:"up"`
-	Down   int64 `json:"down"`
-	Total  int64 `json:"total"`
-	Enable bool  `json:"enable"`
+	Id     int   `json:"id" example:"1"`
+	Up     int64 `json:"up" example:"1048576"`
+	Down   int64 `json:"down" example:"2097152"`
+	Total  int64 `json:"total" example:"10737418240"`
+	Enable bool  `json:"enable" example:"true"`
 }
 
 func (s *InboundService) GetInboundsTrafficSummary() ([]InboundTrafficSummary, error) {

+ 32 - 18
internal/web/service/server.go

@@ -351,13 +351,27 @@ func (s *ServerService) AggregateSystemMetric(metric string, bucketSeconds int,
 }
 
 type LogEntry struct {
-	DateTime    time.Time
-	FromAddress string
-	ToAddress   string
-	Inbound     string
-	Outbound    string
-	Email       string
-	Event       int
+	DateTime    time.Time `json:"DateTime" example:"2025-01-01T12:00:00Z"`
+	FromAddress string    `json:"FromAddress" example:"192.0.2.10:54321"`
+	ToAddress   string    `json:"ToAddress" example:"example.com:443"`
+	Inbound     string    `json:"Inbound" example:"inbound-443"`
+	Outbound    string    `json:"Outbound" example:"direct"`
+	Email       string    `json:"Email" example:"[email protected]"`
+	Event       int       `json:"Event" example:"0"`
+}
+
+type NewUUIDResponse struct {
+	UUID string `json:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"`
+}
+
+type MLDSA65Response struct {
+	Seed   string `json:"seed" example:"mldsa65-seed"`
+	Verify string `json:"verify" example:"mldsa65-verify"`
+}
+
+type MLKEM768Response struct {
+	Seed   string `json:"seed" example:"mlkem768-seed"`
+	Client string `json:"client" example:"mlkem768-client"`
 }
 
 func getPublicIP(url string) string {
@@ -2328,7 +2342,7 @@ func (s *ServerService) GetNewX25519Cert() (any, error) {
 	return keyPair, nil
 }
 
-func (s *ServerService) GetNewmldsa65() (any, error) {
+func (s *ServerService) GetNewmldsa65() (*MLDSA65Response, error) {
 	// Run the command
 	cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "mldsa65")
 	var out bytes.Buffer
@@ -2343,9 +2357,9 @@ func (s *ServerService) GetNewmldsa65() (any, error) {
 		return nil, err
 	}
 
-	keyPair := map[string]any{
-		"seed":   seed,
-		"verify": verify,
+	keyPair := &MLDSA65Response{
+		Seed:   seed,
+		Verify: verify,
 	}
 
 	return keyPair, nil
@@ -2636,18 +2650,18 @@ func vlessEncAuthID(label string) string {
 	}
 }
 
-func (s *ServerService) GetNewUUID() (map[string]string, error) {
+func (s *ServerService) GetNewUUID() (*NewUUIDResponse, error) {
 	newUUID, err := uuid.NewRandom()
 	if err != nil {
 		return nil, fmt.Errorf("failed to generate UUID: %w", err)
 	}
 
-	return map[string]string{
-		"uuid": newUUID.String(),
+	return &NewUUIDResponse{
+		UUID: newUUID.String(),
 	}, nil
 }
 
-func (s *ServerService) GetNewmlkem768() (any, error) {
+func (s *ServerService) GetNewmlkem768() (*MLKEM768Response, error) {
 	// Run the command
 	cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "mlkem768")
 	var out bytes.Buffer
@@ -2662,9 +2676,9 @@ func (s *ServerService) GetNewmlkem768() (any, error) {
 		return nil, err
 	}
 
-	keyPair := map[string]any{
-		"seed":   seed,
-		"client": client,
+	keyPair := &MLKEM768Response{
+		Seed:   seed,
+		Client: client,
 	}
 
 	return keyPair, nil

+ 5 - 5
internal/xray/traffic.go

@@ -3,9 +3,9 @@ package xray
 // Traffic represents network traffic statistics for Xray connections.
 // It tracks upload and download bytes for inbound or outbound traffic.
 type Traffic struct {
-	IsInbound  bool
-	IsOutbound bool
-	Tag        string
-	Up         int64
-	Down       int64
+	IsInbound  bool   `json:"IsInbound" example:"true"`
+	IsOutbound bool   `json:"IsOutbound" example:"false"`
+	Tag        string `json:"Tag" example:"inbound-443"`
+	Up         int64  `json:"Up" example:"1048576"`
+	Down       int64  `json:"Down" example:"2097152"`
 }

+ 9 - 0
tools/openapigen/main.go

@@ -73,6 +73,7 @@ func run(root, outDir string) error {
 			Path: resolveRel(root, "internal/xray"),
 			StructAllow: setOf(
 				"ClientTraffic",
+				"Traffic",
 			),
 		},
 		{
@@ -90,6 +91,14 @@ func run(root, outDir string) error {
 			Path: resolveRel(root, "internal/web/service"),
 			StructAllow: setOf(
 				"InboundOption",
+				"ClientSlim",
+				"ClientPageResponse",
+				"ClientsSummary",
+				"InboundTrafficSummary",
+				"LogEntry",
+				"NewUUIDResponse",
+				"MLDSA65Response",
+				"MLKEM768Response",
 				"NodeMutationRequest",
 				"NodeView",
 				"ProbeResultUI",

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.