From ed6bc1d898a982d318ef653501ca0ab48d047460 Mon Sep 17 00:00:00 2001 From: Gleb Gudkov Date: Fri, 4 Sep 2026 16:23:41 +0300 Subject: [PATCH] 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. --- docs/content/docs/en/reference/api/server.mdx | 20 +- .../en/reference/api/subscription-server.mdx | 23 +- docs/public/openapi.json | 1719 +++++++++++++++-- frontend/public/openapi.json | 1719 +++++++++++++++-- frontend/scripts/build-openapi.mjs | 48 +- frontend/src/generated/examples.ts | 120 ++ frontend/src/generated/schemas.ts | 382 ++++ frontend/src/generated/types.ts | 82 + frontend/src/generated/zod.ts | 91 + frontend/src/pages/api-docs/ApiDocsPage.css | 23 + frontend/src/pages/api-docs/ApiDocsPage.tsx | 26 +- frontend/src/pages/api-docs/endpoints.ts | 197 +- .../src/pages/api-docs/websocket-events.ts | 325 ++++ .../test/openapi-runtime-contracts.test.ts | 228 +++ internal/web/routes_contract_test.go | 2 +- internal/web/service/client_paging.go | 60 +- internal/web/service/inbound_traffic.go | 10 +- internal/web/service/server.go | 50 +- internal/xray/traffic.go | 10 +- tools/openapigen/main.go | 9 + 20 files changed, 4609 insertions(+), 535 deletions(-) create mode 100644 frontend/src/pages/api-docs/websocket-events.ts create mode 100644 frontend/src/test/openapi-runtime-contracts.test.ts diff --git a/docs/content/docs/en/reference/api/server.mdx b/docs/content/docs/en/reference/api/server.mdx index 6a27217d2..a25601036 100644 --- a/docs/content/docs/en/reference/api/server.mdx +++ b/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 diff --git a/docs/content/docs/en/reference/api/subscription-server.mdx b/docs/content/docs/en/reference/api/subscription-server.mdx index d5a7540f3..0469fd6f0 100644 --- a/docs/content/docs/en/reference/api/subscription-server.mdx +++ b/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} - + ); } \ No newline at end of file diff --git a/docs/public/openapi.json b/docs/public/openapi.json index f80ea544b..90d60585d 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -1271,6 +1271,56 @@ ], "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": { @@ -1420,6 +1470,105 @@ ], "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": "alice@example.com", + "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": { @@ -1517,6 +1666,80 @@ ], "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": [ + "bob@example.com" + ], + "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": [ + "alice@example.com" + ], + "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": { @@ -2363,6 +2586,118 @@ ], "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": "alice@example.com", + "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": { @@ -2380,6 +2715,18 @@ ], "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": { @@ -3435,6 +3782,41 @@ ], "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": { @@ -3454,6 +3836,38 @@ "username" ], "type": "object" + }, + "WebSocketEnvelope": { + "type": "object", + "required": [ + "type", + "payload", + "time" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "status", + "traffic", + "client_stats", + "inbounds", + "outbounds", + "nodes", + "notification", + "xray_state", + "invalidate" + ] + }, + "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." + } + } } } }, @@ -5360,12 +5774,16 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "$ref": "#/components/schemas/NewUUIDResponse" + } } }, "example": { "success": true, - "obj": "550e8400-e29b-41d4-a716-446655440000" + "obj": { + "uuid": "550e8400-e29b-41d4-a716-446655440000" + } } } } @@ -5494,7 +5912,7 @@ "tags": [ "Server" ], - "summary": "Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.", + "summary": "Generate a new ML-DSA-65 keypair. Returns {seed, verify}.", "operationId": "get_panel_api_server_getNewmldsa65", "responses": { "200": { @@ -5510,15 +5928,16 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "$ref": "#/components/schemas/MLDSA65Response" + } } }, "example": { "success": true, "obj": { - "privateKey": "mdsa65priv...", - "publicKey": "mdsa65pub...", - "seed": "random-seed..." + "seed": "mldsa65-seed", + "verify": "mldsa65-verify" } } } @@ -5532,7 +5951,7 @@ "tags": [ "Server" ], - "summary": "Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.", + "summary": "Generate a new ML-KEM-768 keypair. Returns {seed, client}.", "operationId": "get_panel_api_server_getNewmlkem768", "responses": { "200": { @@ -5548,14 +5967,16 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "$ref": "#/components/schemas/MLKEM768Response" + } } }, "example": { "success": true, "obj": { - "clientKey": "mlkem768-client...", - "serverKey": "mlkem768-server..." + "client": "mlkem768-client", + "seed": "mlkem768-seed" } } } @@ -5976,12 +6397,20 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "type": "array", + "items": { + "type": "string" + } + } } }, "example": { "success": true, - "obj": "2025/01/01 12:00:00 [INFO] Server started\n2025/01/01 12:00:01 [INFO] Xray is running" + "obj": [ + "2025/01/01 12:00:00 [INFO] Server started", + "2025/01/01 12:00:01 [INFO] Xray is running" + ] } } } @@ -6049,12 +6478,27 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LogEntry" + } + } } }, "example": { "success": true, - "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" + "obj": [ + { + "DateTime": "2025-01-01T12:00:00Z", + "Email": "alice@example.com", + "Event": 0, + "FromAddress": "192.0.2.10:54321", + "Inbound": "inbound-443", + "Outbound": "direct", + "ToAddress": "example.com:443" + } + ] } } } @@ -6733,26 +7177,28 @@ { "name": "page", "in": "query", - "required": true, + "required": false, "description": "1-indexed page number. Defaults to 1.", "schema": { - "type": "integer" + "type": "integer", + "default": 1 } }, { "name": "pageSize", "in": "query", - "required": true, + "required": false, "description": "Rows per page. Defaults to 25, capped at 200.", "schema": { - "type": "integer" + "type": "integer", + "default": 25 } }, { "name": "search", "in": "query", - "required": true, - "description": "Case-insensitive substring match on email / subId / comment.", + "required": false, + "description": "Case-insensitive substring match on email, subId, comment, UUID, password, auth or Telegram ID.", "schema": { "type": "string" } @@ -6760,8 +7206,8 @@ { "name": "filter", "in": "query", - "required": true, - "description": "Status bucket: online | active | deactive | depleted | expiring.", + "required": false, + "description": "CSV status buckets: online, active, deactive, depleted or expiring. Values are ORed.", "schema": { "type": "string" } @@ -6769,8 +7215,17 @@ { "name": "protocol", "in": "query", - "required": true, - "description": "Match clients attached to at least one inbound of this protocol (vless, vmess, trojan, shadowsocks, ...).", + "required": false, + "description": "CSV inbound protocols: vmess, vless, trojan, shadowsocks, wireguard, hysteria, http, mixed, tunnel, tun, mtproto or amneziawg. Values are ORed.", + "schema": { + "type": "string" + } + }, + { + "name": "inbound", + "in": "query", + "required": false, + "description": "CSV positive inbound IDs. Values are ORed; invalid or non-positive IDs are ignored.", "schema": { "type": "string" } @@ -6778,17 +7233,116 @@ { "name": "sort", "in": "query", - "required": true, - "description": "Sort key: enable | email | inboundIds | traffic | remaining | expiryTime.", + "required": false, + "description": "Sort key. An omitted or unknown value falls back to client ID ascending.", "schema": { - "type": "string" + "type": "string", + "enum": [ + "enable", + "email", + "inboundIds", + "traffic", + "remaining", + "expiryTime", + "createdAt", + "updatedAt", + "lastOnline" + ] } }, { "name": "order", "in": "query", - "required": true, - "description": "ascend or descend.", + "required": false, + "description": "Sort direction. Only descend selects descending order; otherwise ascending.", + "schema": { + "type": "string", + "enum": [ + "ascend", + "descend" + ] + } + }, + { + "name": "expiryFrom", + "in": "query", + "required": false, + "description": "Inclusive minimum expiry time in Unix milliseconds. Zero or negative means unset.", + "schema": { + "type": "integer" + } + }, + { + "name": "expiryTo", + "in": "query", + "required": false, + "description": "Inclusive maximum expiry time in Unix milliseconds. Zero or negative means unbounded.", + "schema": { + "type": "integer" + } + }, + { + "name": "usageFrom", + "in": "query", + "required": false, + "description": "Inclusive minimum combined upload and download usage in bytes. Zero means unset.", + "schema": { + "type": "integer" + } + }, + { + "name": "usageTo", + "in": "query", + "required": false, + "description": "Inclusive maximum combined upload and download usage in bytes. Zero means unbounded.", + "schema": { + "type": "integer" + } + }, + { + "name": "autoRenew", + "in": "query", + "required": false, + "description": "on selects clients with an interval or calendar-day reset; off selects clients without either.", + "schema": { + "type": "string", + "enum": [ + "on", + "off" + ] + } + }, + { + "name": "hasTgId", + "in": "query", + "required": false, + "description": "yes selects clients with a non-zero Telegram ID; no selects clients without one.", + "schema": { + "type": "string", + "enum": [ + "yes", + "no" + ] + } + }, + { + "name": "hasComment", + "in": "query", + "required": false, + "description": "yes selects clients with a non-blank comment; no selects clients without one.", + "schema": { + "type": "string", + "enum": [ + "yes", + "no" + ] + } + }, + { + "name": "group", + "in": "query", + "required": false, + "description": "CSV group names, matched case-insensitively after trimming. Values are ORed.", "schema": { "type": "string" } @@ -6808,55 +7362,61 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "$ref": "#/components/schemas/ClientPageResponse" + } } }, "example": { "success": true, "obj": { + "filtered": 47, + "groups": [ + "staff", + "trial" + ], "items": [ { + "comment": "Primary device", + "createdAt": 1735000000000, "email": "alice@example.com", - "subId": "abcd1234", "enable": true, - "totalGB": 53687091200, "expiryTime": 1735689600000, - "limitIp": 0, - "limitHwid": 0, - "reset": 0, + "group": "staff", "inboundIds": [ 3, 5 ], - "traffic": { - "up": 1024, - "down": 4096, - "enable": true - }, - "createdAt": 1735000000000, + "limitHwid": 0, + "limitIp": 0, + "reset": 0, + "resetDay": 0, + "resetMax": 0, + "subId": "abcd1234", + "totalGB": 53687091200, + "traffic": null, "updatedAt": 1735100000000 } ], - "total": 2000, - "filtered": 47, "page": 1, "pageSize": 25, "summary": { - "total": 2000, "active": 1850, - "onlineCount": 1, - "depletedCount": 0, - "expiringCount": 0, + "deactive": [ + "bob@example.com" + ], "deactiveCount": 150, + "depleted": [], + "depletedCount": 0, + "expiring": [], + "expiringCount": 0, "online": [ "alice@example.com" ], - "depleted": [], - "expiring": [], - "deactive": [ - "bob@example.com" - ] - } + "onlineCount": 1, + "total": 2000 + }, + "total": 2000 } } } @@ -13563,6 +14123,44 @@ } } } + }, + "head": { + "tags": [ + "Subscription Server" + ], + "summary": "Return the same status and subscription metadata headers as GET without a response body.", + "operationId": "head_subPath_subid", + "parameters": [ + { + "name": "subid", + "in": "path", + "required": true, + "description": "Client subscription ID.", + "schema": { + "type": "string" + } + }, + { + "name": "subPath", + "in": "path", + "required": true, + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "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." + } + } } }, "/{jsonPath}{subid}": { @@ -13613,6 +14211,44 @@ } } } + }, + "head": { + "tags": [ + "Subscription Server" + ], + "summary": "Return the JSON subscription status and metadata headers without a body. Registered only when JSON subscriptions are enabled.", + "operationId": "head_jsonPath_subid", + "parameters": [ + { + "name": "subid", + "in": "path", + "required": true, + "description": "Client subscription ID.", + "schema": { + "type": "string" + } + }, + { + "name": "jsonPath", + "in": "path", + "required": true, + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "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." + } + } } }, "/{clashPath}{subid}": { @@ -13663,6 +14299,44 @@ } } } + }, + "head": { + "tags": [ + "Subscription Server" + ], + "summary": "Return the Clash subscription status and metadata headers without a body. Registered only when Clash subscriptions are enabled.", + "operationId": "head_clashPath_subid", + "parameters": [ + { + "name": "subid", + "in": "path", + "required": true, + "description": "Client subscription ID.", + "schema": { + "type": "string" + } + }, + { + "name": "clashPath", + "in": "path", + "required": true, + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "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." + } + } } }, "/ws": { @@ -13673,173 +14347,822 @@ "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.", "operationId": "get_ws", "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "msg": { - "type": "string" - }, - "obj": {} - } - } - } - } + "101": { + "description": "Switching Protocols. WebSocket messages use WebSocketEnvelope." + }, + "401": { + "description": "No authenticated panel session cookie." + } + }, + "security": [ + { + "cookieAuth": [] } - } - } - }, - "→ type: status": { - "ws": { - "tags": [ - "WebSocket" ], - "summary": "Server health snapshot pushed every 2 seconds. Contains CPU, memory, swap, disk, network IO, load, and Xray state — same shape as GET /panel/api/server/status.", - "operationId": "ws_type_status", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { + "x-websocket-events": [ + { + "type": "status", + "summary": "Server health snapshot pushed every two seconds; same payload as server/status obj.", + "payloadSchema": { + "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": { "type": "object", + "required": [ + "current", + "total" + ], "properties": { - "success": { - "type": "boolean" + "current": { + "type": "integer", + "format": "int64" }, - "msg": { - "type": "string" - }, - "obj": {} + "total": { + "type": "integer", + "format": "int64" + } } }, - "example": { - "type": "status", - "data": { - "cpu": 12.5, - "mem": { - "current": 2147483648, - "total": 8589934592 + "swap": { + "type": "object", + "required": [ + "current", + "total" + ], + "properties": { + "current": { + "type": "integer", + "format": "int64" }, - "xray": { - "state": "running" + "total": { + "type": "integer", + "format": "int64" + } + } + }, + "disk": { + "type": "object", + "required": [ + "current", + "total" + ], + "properties": { + "current": { + "type": "integer", + "format": "int64" + }, + "total": { + "type": "integer", + "format": "int64" + } + } + }, + "diskIO": { + "type": "object", + "required": [ + "read", + "write" + ], + "properties": { + "read": { + "type": "integer", + "format": "int64" + }, + "write": { + "type": "integer", + "format": "int64" + } + } + }, + "diskTraffic": { + "type": "object", + "required": [ + "read", + "write" + ], + "properties": { + "read": { + "type": "integer", + "format": "int64" + }, + "write": { + "type": "integer", + "format": "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": { + "type": "integer", + "format": "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": { + "type": "integer", + "format": "int64" + }, + "down": { + "type": "integer", + "format": "int64" + }, + "pktUp": { + "type": "integer", + "format": "int64" + }, + "pktDown": { + "type": "integer", + "format": "int64" + } + } + }, + "netTraffic": { + "type": "object", + "required": [ + "sent", + "recv", + "pktSent", + "pktRecv" + ], + "properties": { + "sent": { + "type": "integer", + "format": "int64" + }, + "recv": { + "type": "integer", + "format": "int64" + }, + "pktSent": { + "type": "integer", + "format": "int64" + }, + "pktRecv": { + "type": "integer", + "format": "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": { + "type": "integer", + "format": "int64" + }, + "uptime": { + "type": "integer", + "format": "int64" } } } } + }, + "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": 1735689600000 } - } - } - } - }, - "→ type: xrayState": { - "ws": { - "tags": [ - "WebSocket" - ], - "summary": "Xray process state change. Fired when Xray starts, stops, or encounters an error.", - "operationId": "ws_type_xrayState", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "msg": { - "type": "string" - }, - "obj": {} + }, + { + "type": "traffic", + "summary": "Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.", + "payloadSchema": { + "type": "object", + "required": [ + "onlineClients", + "onlineByGuid", + "activeInbounds", + "lastOnlineMap" + ], + "properties": { + "traffics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Traffic" } }, - "example": { - "type": "xrayState", - "data": "running" - } - } - } - } - } - } - }, - "→ type: notification": { - "ws": { - "tags": [ - "WebSocket" - ], - "summary": "In-panel toast notification. Fired on Xray stop/restart, DB import, panel restart, etc.", - "operationId": "ws_type_notification", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "msg": { - "type": "string" - }, - "obj": {} + "clientTraffics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ClientTraffic" } }, - "example": { - "type": "notification", - "title": "Xray service restarted", - "body": "Xray has been restarted successfully", - "severity": "success" - } - } - } - } - } - } - }, - "→ type: invalidate": { - "ws": { - "tags": [ - "WebSocket" - ], - "summary": "Instructs the UI to re-fetch a resource. Fired when another admin session modifies data (e.g. toggling inbound enable).", - "operationId": "ws_type_invalidate", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "msg": { - "type": "string" - }, - "obj": {} + "nodeTraffics": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/Traffic" } }, - "example": { - "type": "invalidate", - "resource": "inbounds" + "onlineClients": { + "type": "array", + "items": { + "type": "string" + } + }, + "onlineByGuid": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "activeInbounds": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "lastOnlineMap": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + } + }, + "oneOf": [ + { + "required": [ + "traffics", + "clientTraffics" + ] + }, + { + "required": [ + "nodeTraffics" + ] + } + ] + }, + "example": { + "type": "traffic", + "payload": { + "traffics": [ + { + "Down": 2097152, + "IsInbound": true, + "IsOutbound": false, + "Tag": "inbound-443", + "Up": 1048576 + } + ], + "clientTraffics": [ + { + "down": 2097152, + "email": "user1", + "enable": true, + "expiryTime": 1735689600000, + "id": 14825, + "inboundId": 1, + "lastOnline": 1735680000000, + "lastSubFetch": 1735680000000, + "reset": 0, + "resetCount": 0, + "resetDay": 0, + "resetMax": 0, + "subId": "i7tvdpeffi0hvvf1", + "total": 10737418240, + "up": 1048576, + "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c" + } + ], + "onlineClients": [ + "alice@example.com" + ], + "onlineByGuid": { + "panel-guid": [ + "alice@example.com" + ] + }, + "activeInbounds": { + "panel-guid": [ + "inbound-443" + ] + }, + "lastOnlineMap": { + "alice@example.com": 1735689600000 + } + }, + "time": 1735689600000 + } + }, + { + "type": "client_stats", + "summary": "Absolute client counters and/or inbound summaries; snapshot says whether clients is complete or only recently active rows.", + "payloadSchema": { + "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" + ] + } + ] + }, + "example": { + "type": "client_stats", + "payload": { + "snapshot": true, + "clients": [ + { + "down": 2097152, + "email": "user1", + "enable": true, + "expiryTime": 1735689600000, + "id": 14825, + "inboundId": 1, + "lastOnline": 1735680000000, + "lastSubFetch": 1735680000000, + "reset": 0, + "resetCount": 0, + "resetDay": 0, + "resetMax": 0, + "subId": "i7tvdpeffi0hvvf1", + "total": 10737418240, + "up": 1048576, + "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c" + } + ], + "inbounds": [ + { + "down": 2097152, + "enable": true, + "id": 1, + "total": 10737418240, + "up": 1048576 + } + ] + }, + "time": 1735689600000 + } + }, + { + "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": [ + { + "clientStats": [ + { + "down": 2097152, + "email": "user1", + "enable": true, + "expiryTime": 1735689600000, + "id": 14825, + "inboundId": 1, + "lastOnline": 1735680000000, + "lastSubFetch": 1735680000000, + "reset": 0, + "resetCount": 0, + "resetDay": 0, + "resetMax": 0, + "subId": "i7tvdpeffi0hvvf1", + "total": 10737418240, + "up": 1048576, + "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c" + } + ], + "disableFlow": false, + "down": 0, + "enable": true, + "expiryTime": 0, + "fallbackParent": null, + "id": 1, + "lastTrafficResetTime": 0, + "listen": "", + "nodeId": null, + "originNodeGuid": "", + "port": 443, + "protocol": "vless", + "remark": "VLESS-443", + "settings": null, + "shareAddr": "", + "shareAddrStrategy": "node", + "sniffing": null, + "streamSettings": null, + "subSortIndex": 1, + "tag": "in-443-tcp", + "total": 0, + "trafficReset": "never", + "trafficResetDay": 1, + "up": 0 + } + ], + "time": 1735689600000 + } + }, + { + "type": "outbounds", + "summary": "Current outbound traffic rows after the periodic traffic collection.", + "payloadSchema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OutboundTraffics" + } + }, + "example": { + "type": "outbounds", + "payload": [ + { + "down": 0, + "id": 0, + "tag": "", + "total": 0, + "up": 0 + } + ], + "time": 1735689600000 + } + }, + { + "type": "nodes", + "summary": "Current node tree after the heartbeat probe cycle.", + "payloadSchema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NodeView" + } + }, + "example": { + "type": "nodes", + "payload": [ + { + "activeCount": 20, + "address": "node.example.com", + "allowPrivateAddress": false, + "basePath": "/", + "clientCount": 25, + "configDirty": false, + "configDirtyAt": 0, + "cpuPct": 12.5, + "createdAt": 1700000000, + "depletedCount": 1, + "disabledCount": 2, + "enable": true, + "guid": "node-guid", + "hasApiToken": true, + "id": 1, + "inboundCount": 3, + "inboundSyncMode": "all", + "inboundTags": [ + "in-443-tcp" + ], + "lastError": "", + "lastHeartbeat": 1700000000, + "latencyMs": 42, + "memPct": 45.2, + "name": "edge-1", + "netDown": 1048576, + "netUp": 2097152, + "onlineCount": 5, + "outboundTag": "direct", + "panelVersion": "v3.x.x", + "parentGuid": "", + "pinnedCertSha256": "", + "port": 2053, + "remark": "Primary edge", + "scheme": "https", + "status": "online", + "tlsVerifyMode": "verify", + "transitive": false, + "updatedAt": 1700003600, + "uptimeSecs": 86400, + "xrayError": "", + "xrayState": "running", + "xrayVersion": "25.10.31" + } + ], + "time": 1735689600000 + } + }, + { + "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": 1735689600000 + } + }, + { + "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": 1735689600000 + } + }, + { + "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": 1735689600000 } } - } + ] } } } diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index f80ea544b..90d60585d 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1271,6 +1271,56 @@ ], "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": { @@ -1420,6 +1470,105 @@ ], "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": "alice@example.com", + "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": { @@ -1517,6 +1666,80 @@ ], "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": [ + "bob@example.com" + ], + "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": [ + "alice@example.com" + ], + "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": { @@ -2363,6 +2586,118 @@ ], "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": "alice@example.com", + "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": { @@ -2380,6 +2715,18 @@ ], "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": { @@ -3435,6 +3782,41 @@ ], "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": { @@ -3454,6 +3836,38 @@ "username" ], "type": "object" + }, + "WebSocketEnvelope": { + "type": "object", + "required": [ + "type", + "payload", + "time" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "status", + "traffic", + "client_stats", + "inbounds", + "outbounds", + "nodes", + "notification", + "xray_state", + "invalidate" + ] + }, + "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." + } + } } } }, @@ -5360,12 +5774,16 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "$ref": "#/components/schemas/NewUUIDResponse" + } } }, "example": { "success": true, - "obj": "550e8400-e29b-41d4-a716-446655440000" + "obj": { + "uuid": "550e8400-e29b-41d4-a716-446655440000" + } } } } @@ -5494,7 +5912,7 @@ "tags": [ "Server" ], - "summary": "Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.", + "summary": "Generate a new ML-DSA-65 keypair. Returns {seed, verify}.", "operationId": "get_panel_api_server_getNewmldsa65", "responses": { "200": { @@ -5510,15 +5928,16 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "$ref": "#/components/schemas/MLDSA65Response" + } } }, "example": { "success": true, "obj": { - "privateKey": "mdsa65priv...", - "publicKey": "mdsa65pub...", - "seed": "random-seed..." + "seed": "mldsa65-seed", + "verify": "mldsa65-verify" } } } @@ -5532,7 +5951,7 @@ "tags": [ "Server" ], - "summary": "Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.", + "summary": "Generate a new ML-KEM-768 keypair. Returns {seed, client}.", "operationId": "get_panel_api_server_getNewmlkem768", "responses": { "200": { @@ -5548,14 +5967,16 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "$ref": "#/components/schemas/MLKEM768Response" + } } }, "example": { "success": true, "obj": { - "clientKey": "mlkem768-client...", - "serverKey": "mlkem768-server..." + "client": "mlkem768-client", + "seed": "mlkem768-seed" } } } @@ -5976,12 +6397,20 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "type": "array", + "items": { + "type": "string" + } + } } }, "example": { "success": true, - "obj": "2025/01/01 12:00:00 [INFO] Server started\n2025/01/01 12:00:01 [INFO] Xray is running" + "obj": [ + "2025/01/01 12:00:00 [INFO] Server started", + "2025/01/01 12:00:01 [INFO] Xray is running" + ] } } } @@ -6049,12 +6478,27 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LogEntry" + } + } } }, "example": { "success": true, - "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" + "obj": [ + { + "DateTime": "2025-01-01T12:00:00Z", + "Email": "alice@example.com", + "Event": 0, + "FromAddress": "192.0.2.10:54321", + "Inbound": "inbound-443", + "Outbound": "direct", + "ToAddress": "example.com:443" + } + ] } } } @@ -6733,26 +7177,28 @@ { "name": "page", "in": "query", - "required": true, + "required": false, "description": "1-indexed page number. Defaults to 1.", "schema": { - "type": "integer" + "type": "integer", + "default": 1 } }, { "name": "pageSize", "in": "query", - "required": true, + "required": false, "description": "Rows per page. Defaults to 25, capped at 200.", "schema": { - "type": "integer" + "type": "integer", + "default": 25 } }, { "name": "search", "in": "query", - "required": true, - "description": "Case-insensitive substring match on email / subId / comment.", + "required": false, + "description": "Case-insensitive substring match on email, subId, comment, UUID, password, auth or Telegram ID.", "schema": { "type": "string" } @@ -6760,8 +7206,8 @@ { "name": "filter", "in": "query", - "required": true, - "description": "Status bucket: online | active | deactive | depleted | expiring.", + "required": false, + "description": "CSV status buckets: online, active, deactive, depleted or expiring. Values are ORed.", "schema": { "type": "string" } @@ -6769,8 +7215,17 @@ { "name": "protocol", "in": "query", - "required": true, - "description": "Match clients attached to at least one inbound of this protocol (vless, vmess, trojan, shadowsocks, ...).", + "required": false, + "description": "CSV inbound protocols: vmess, vless, trojan, shadowsocks, wireguard, hysteria, http, mixed, tunnel, tun, mtproto or amneziawg. Values are ORed.", + "schema": { + "type": "string" + } + }, + { + "name": "inbound", + "in": "query", + "required": false, + "description": "CSV positive inbound IDs. Values are ORed; invalid or non-positive IDs are ignored.", "schema": { "type": "string" } @@ -6778,17 +7233,116 @@ { "name": "sort", "in": "query", - "required": true, - "description": "Sort key: enable | email | inboundIds | traffic | remaining | expiryTime.", + "required": false, + "description": "Sort key. An omitted or unknown value falls back to client ID ascending.", "schema": { - "type": "string" + "type": "string", + "enum": [ + "enable", + "email", + "inboundIds", + "traffic", + "remaining", + "expiryTime", + "createdAt", + "updatedAt", + "lastOnline" + ] } }, { "name": "order", "in": "query", - "required": true, - "description": "ascend or descend.", + "required": false, + "description": "Sort direction. Only descend selects descending order; otherwise ascending.", + "schema": { + "type": "string", + "enum": [ + "ascend", + "descend" + ] + } + }, + { + "name": "expiryFrom", + "in": "query", + "required": false, + "description": "Inclusive minimum expiry time in Unix milliseconds. Zero or negative means unset.", + "schema": { + "type": "integer" + } + }, + { + "name": "expiryTo", + "in": "query", + "required": false, + "description": "Inclusive maximum expiry time in Unix milliseconds. Zero or negative means unbounded.", + "schema": { + "type": "integer" + } + }, + { + "name": "usageFrom", + "in": "query", + "required": false, + "description": "Inclusive minimum combined upload and download usage in bytes. Zero means unset.", + "schema": { + "type": "integer" + } + }, + { + "name": "usageTo", + "in": "query", + "required": false, + "description": "Inclusive maximum combined upload and download usage in bytes. Zero means unbounded.", + "schema": { + "type": "integer" + } + }, + { + "name": "autoRenew", + "in": "query", + "required": false, + "description": "on selects clients with an interval or calendar-day reset; off selects clients without either.", + "schema": { + "type": "string", + "enum": [ + "on", + "off" + ] + } + }, + { + "name": "hasTgId", + "in": "query", + "required": false, + "description": "yes selects clients with a non-zero Telegram ID; no selects clients without one.", + "schema": { + "type": "string", + "enum": [ + "yes", + "no" + ] + } + }, + { + "name": "hasComment", + "in": "query", + "required": false, + "description": "yes selects clients with a non-blank comment; no selects clients without one.", + "schema": { + "type": "string", + "enum": [ + "yes", + "no" + ] + } + }, + { + "name": "group", + "in": "query", + "required": false, + "description": "CSV group names, matched case-insensitively after trimming. Values are ORed.", "schema": { "type": "string" } @@ -6808,55 +7362,61 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "$ref": "#/components/schemas/ClientPageResponse" + } } }, "example": { "success": true, "obj": { + "filtered": 47, + "groups": [ + "staff", + "trial" + ], "items": [ { + "comment": "Primary device", + "createdAt": 1735000000000, "email": "alice@example.com", - "subId": "abcd1234", "enable": true, - "totalGB": 53687091200, "expiryTime": 1735689600000, - "limitIp": 0, - "limitHwid": 0, - "reset": 0, + "group": "staff", "inboundIds": [ 3, 5 ], - "traffic": { - "up": 1024, - "down": 4096, - "enable": true - }, - "createdAt": 1735000000000, + "limitHwid": 0, + "limitIp": 0, + "reset": 0, + "resetDay": 0, + "resetMax": 0, + "subId": "abcd1234", + "totalGB": 53687091200, + "traffic": null, "updatedAt": 1735100000000 } ], - "total": 2000, - "filtered": 47, "page": 1, "pageSize": 25, "summary": { - "total": 2000, "active": 1850, - "onlineCount": 1, - "depletedCount": 0, - "expiringCount": 0, + "deactive": [ + "bob@example.com" + ], "deactiveCount": 150, + "depleted": [], + "depletedCount": 0, + "expiring": [], + "expiringCount": 0, "online": [ "alice@example.com" ], - "depleted": [], - "expiring": [], - "deactive": [ - "bob@example.com" - ] - } + "onlineCount": 1, + "total": 2000 + }, + "total": 2000 } } } @@ -13563,6 +14123,44 @@ } } } + }, + "head": { + "tags": [ + "Subscription Server" + ], + "summary": "Return the same status and subscription metadata headers as GET without a response body.", + "operationId": "head_subPath_subid", + "parameters": [ + { + "name": "subid", + "in": "path", + "required": true, + "description": "Client subscription ID.", + "schema": { + "type": "string" + } + }, + { + "name": "subPath", + "in": "path", + "required": true, + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "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." + } + } } }, "/{jsonPath}{subid}": { @@ -13613,6 +14211,44 @@ } } } + }, + "head": { + "tags": [ + "Subscription Server" + ], + "summary": "Return the JSON subscription status and metadata headers without a body. Registered only when JSON subscriptions are enabled.", + "operationId": "head_jsonPath_subid", + "parameters": [ + { + "name": "subid", + "in": "path", + "required": true, + "description": "Client subscription ID.", + "schema": { + "type": "string" + } + }, + { + "name": "jsonPath", + "in": "path", + "required": true, + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "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." + } + } } }, "/{clashPath}{subid}": { @@ -13663,6 +14299,44 @@ } } } + }, + "head": { + "tags": [ + "Subscription Server" + ], + "summary": "Return the Clash subscription status and metadata headers without a body. Registered only when Clash subscriptions are enabled.", + "operationId": "head_clashPath_subid", + "parameters": [ + { + "name": "subid", + "in": "path", + "required": true, + "description": "Client subscription ID.", + "schema": { + "type": "string" + } + }, + { + "name": "clashPath", + "in": "path", + "required": true, + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "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." + } + } } }, "/ws": { @@ -13673,173 +14347,822 @@ "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.", "operationId": "get_ws", "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "msg": { - "type": "string" - }, - "obj": {} - } - } - } - } + "101": { + "description": "Switching Protocols. WebSocket messages use WebSocketEnvelope." + }, + "401": { + "description": "No authenticated panel session cookie." + } + }, + "security": [ + { + "cookieAuth": [] } - } - } - }, - "→ type: status": { - "ws": { - "tags": [ - "WebSocket" ], - "summary": "Server health snapshot pushed every 2 seconds. Contains CPU, memory, swap, disk, network IO, load, and Xray state — same shape as GET /panel/api/server/status.", - "operationId": "ws_type_status", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { + "x-websocket-events": [ + { + "type": "status", + "summary": "Server health snapshot pushed every two seconds; same payload as server/status obj.", + "payloadSchema": { + "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": { "type": "object", + "required": [ + "current", + "total" + ], "properties": { - "success": { - "type": "boolean" + "current": { + "type": "integer", + "format": "int64" }, - "msg": { - "type": "string" - }, - "obj": {} + "total": { + "type": "integer", + "format": "int64" + } } }, - "example": { - "type": "status", - "data": { - "cpu": 12.5, - "mem": { - "current": 2147483648, - "total": 8589934592 + "swap": { + "type": "object", + "required": [ + "current", + "total" + ], + "properties": { + "current": { + "type": "integer", + "format": "int64" }, - "xray": { - "state": "running" + "total": { + "type": "integer", + "format": "int64" + } + } + }, + "disk": { + "type": "object", + "required": [ + "current", + "total" + ], + "properties": { + "current": { + "type": "integer", + "format": "int64" + }, + "total": { + "type": "integer", + "format": "int64" + } + } + }, + "diskIO": { + "type": "object", + "required": [ + "read", + "write" + ], + "properties": { + "read": { + "type": "integer", + "format": "int64" + }, + "write": { + "type": "integer", + "format": "int64" + } + } + }, + "diskTraffic": { + "type": "object", + "required": [ + "read", + "write" + ], + "properties": { + "read": { + "type": "integer", + "format": "int64" + }, + "write": { + "type": "integer", + "format": "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": { + "type": "integer", + "format": "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": { + "type": "integer", + "format": "int64" + }, + "down": { + "type": "integer", + "format": "int64" + }, + "pktUp": { + "type": "integer", + "format": "int64" + }, + "pktDown": { + "type": "integer", + "format": "int64" + } + } + }, + "netTraffic": { + "type": "object", + "required": [ + "sent", + "recv", + "pktSent", + "pktRecv" + ], + "properties": { + "sent": { + "type": "integer", + "format": "int64" + }, + "recv": { + "type": "integer", + "format": "int64" + }, + "pktSent": { + "type": "integer", + "format": "int64" + }, + "pktRecv": { + "type": "integer", + "format": "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": { + "type": "integer", + "format": "int64" + }, + "uptime": { + "type": "integer", + "format": "int64" } } } } + }, + "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": 1735689600000 } - } - } - } - }, - "→ type: xrayState": { - "ws": { - "tags": [ - "WebSocket" - ], - "summary": "Xray process state change. Fired when Xray starts, stops, or encounters an error.", - "operationId": "ws_type_xrayState", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "msg": { - "type": "string" - }, - "obj": {} + }, + { + "type": "traffic", + "summary": "Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.", + "payloadSchema": { + "type": "object", + "required": [ + "onlineClients", + "onlineByGuid", + "activeInbounds", + "lastOnlineMap" + ], + "properties": { + "traffics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Traffic" } }, - "example": { - "type": "xrayState", - "data": "running" - } - } - } - } - } - } - }, - "→ type: notification": { - "ws": { - "tags": [ - "WebSocket" - ], - "summary": "In-panel toast notification. Fired on Xray stop/restart, DB import, panel restart, etc.", - "operationId": "ws_type_notification", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "msg": { - "type": "string" - }, - "obj": {} + "clientTraffics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ClientTraffic" } }, - "example": { - "type": "notification", - "title": "Xray service restarted", - "body": "Xray has been restarted successfully", - "severity": "success" - } - } - } - } - } - } - }, - "→ type: invalidate": { - "ws": { - "tags": [ - "WebSocket" - ], - "summary": "Instructs the UI to re-fetch a resource. Fired when another admin session modifies data (e.g. toggling inbound enable).", - "operationId": "ws_type_invalidate", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "msg": { - "type": "string" - }, - "obj": {} + "nodeTraffics": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/Traffic" } }, - "example": { - "type": "invalidate", - "resource": "inbounds" + "onlineClients": { + "type": "array", + "items": { + "type": "string" + } + }, + "onlineByGuid": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "activeInbounds": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "lastOnlineMap": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + } + }, + "oneOf": [ + { + "required": [ + "traffics", + "clientTraffics" + ] + }, + { + "required": [ + "nodeTraffics" + ] + } + ] + }, + "example": { + "type": "traffic", + "payload": { + "traffics": [ + { + "Down": 2097152, + "IsInbound": true, + "IsOutbound": false, + "Tag": "inbound-443", + "Up": 1048576 + } + ], + "clientTraffics": [ + { + "down": 2097152, + "email": "user1", + "enable": true, + "expiryTime": 1735689600000, + "id": 14825, + "inboundId": 1, + "lastOnline": 1735680000000, + "lastSubFetch": 1735680000000, + "reset": 0, + "resetCount": 0, + "resetDay": 0, + "resetMax": 0, + "subId": "i7tvdpeffi0hvvf1", + "total": 10737418240, + "up": 1048576, + "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c" + } + ], + "onlineClients": [ + "alice@example.com" + ], + "onlineByGuid": { + "panel-guid": [ + "alice@example.com" + ] + }, + "activeInbounds": { + "panel-guid": [ + "inbound-443" + ] + }, + "lastOnlineMap": { + "alice@example.com": 1735689600000 + } + }, + "time": 1735689600000 + } + }, + { + "type": "client_stats", + "summary": "Absolute client counters and/or inbound summaries; snapshot says whether clients is complete or only recently active rows.", + "payloadSchema": { + "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" + ] + } + ] + }, + "example": { + "type": "client_stats", + "payload": { + "snapshot": true, + "clients": [ + { + "down": 2097152, + "email": "user1", + "enable": true, + "expiryTime": 1735689600000, + "id": 14825, + "inboundId": 1, + "lastOnline": 1735680000000, + "lastSubFetch": 1735680000000, + "reset": 0, + "resetCount": 0, + "resetDay": 0, + "resetMax": 0, + "subId": "i7tvdpeffi0hvvf1", + "total": 10737418240, + "up": 1048576, + "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c" + } + ], + "inbounds": [ + { + "down": 2097152, + "enable": true, + "id": 1, + "total": 10737418240, + "up": 1048576 + } + ] + }, + "time": 1735689600000 + } + }, + { + "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": [ + { + "clientStats": [ + { + "down": 2097152, + "email": "user1", + "enable": true, + "expiryTime": 1735689600000, + "id": 14825, + "inboundId": 1, + "lastOnline": 1735680000000, + "lastSubFetch": 1735680000000, + "reset": 0, + "resetCount": 0, + "resetDay": 0, + "resetMax": 0, + "subId": "i7tvdpeffi0hvvf1", + "total": 10737418240, + "up": 1048576, + "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c" + } + ], + "disableFlow": false, + "down": 0, + "enable": true, + "expiryTime": 0, + "fallbackParent": null, + "id": 1, + "lastTrafficResetTime": 0, + "listen": "", + "nodeId": null, + "originNodeGuid": "", + "port": 443, + "protocol": "vless", + "remark": "VLESS-443", + "settings": null, + "shareAddr": "", + "shareAddrStrategy": "node", + "sniffing": null, + "streamSettings": null, + "subSortIndex": 1, + "tag": "in-443-tcp", + "total": 0, + "trafficReset": "never", + "trafficResetDay": 1, + "up": 0 + } + ], + "time": 1735689600000 + } + }, + { + "type": "outbounds", + "summary": "Current outbound traffic rows after the periodic traffic collection.", + "payloadSchema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OutboundTraffics" + } + }, + "example": { + "type": "outbounds", + "payload": [ + { + "down": 0, + "id": 0, + "tag": "", + "total": 0, + "up": 0 + } + ], + "time": 1735689600000 + } + }, + { + "type": "nodes", + "summary": "Current node tree after the heartbeat probe cycle.", + "payloadSchema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NodeView" + } + }, + "example": { + "type": "nodes", + "payload": [ + { + "activeCount": 20, + "address": "node.example.com", + "allowPrivateAddress": false, + "basePath": "/", + "clientCount": 25, + "configDirty": false, + "configDirtyAt": 0, + "cpuPct": 12.5, + "createdAt": 1700000000, + "depletedCount": 1, + "disabledCount": 2, + "enable": true, + "guid": "node-guid", + "hasApiToken": true, + "id": 1, + "inboundCount": 3, + "inboundSyncMode": "all", + "inboundTags": [ + "in-443-tcp" + ], + "lastError": "", + "lastHeartbeat": 1700000000, + "latencyMs": 42, + "memPct": 45.2, + "name": "edge-1", + "netDown": 1048576, + "netUp": 2097152, + "onlineCount": 5, + "outboundTag": "direct", + "panelVersion": "v3.x.x", + "parentGuid": "", + "pinnedCertSha256": "", + "port": 2053, + "remark": "Primary edge", + "scheme": "https", + "status": "online", + "tlsVerifyMode": "verify", + "transitive": false, + "updatedAt": 1700003600, + "uptimeSecs": 86400, + "xrayError": "", + "xrayState": "running", + "xrayVersion": "25.10.31" + } + ], + "time": 1735689600000 + } + }, + { + "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": 1735689600000 + } + }, + { + "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": 1735689600000 + } + }, + { + "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": 1735689600000 } } - } + ] } } } diff --git a/frontend/scripts/build-openapi.mjs b/frontend/scripts/build-openapi.mjs index 123919a45..fbeabadfd 100644 --- a/frontend/scripts/build-openapi.mjs +++ b/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, diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 4f2551c4c..7ef73b8e4 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -300,6 +300,55 @@ export const EXAMPLES: Record = { "flowOverride": "", "inboundId": 0 }, + "ClientPageResponse": { + "filtered": 47, + "groups": [ + "staff", + "trial" + ], + "items": [ + { + "comment": "Primary device", + "createdAt": 1735000000000, + "email": "alice@example.com", + "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": [ + "bob@example.com" + ], + "deactiveCount": 150, + "depleted": [], + "depletedCount": 0, + "expiring": [], + "expiringCount": 0, + "online": [ + "alice@example.com" + ], + "onlineCount": 1, + "total": 2000 + }, + "total": 2000 + }, "ClientRecord": { "adTag": "", "allowedIPs": "", @@ -337,6 +386,27 @@ export const EXAMPLES: Record = { "ClientReverse": { "tag": "" }, + "ClientSlim": { + "comment": "Primary device", + "createdAt": 1735000000000, + "email": "alice@example.com", + "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 = { "up": 1048576, "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c" }, + "ClientsSummary": { + "active": 1850, + "deactive": [ + "bob@example.com" + ], + "deactiveCount": 150, + "depleted": [], + "depletedCount": 0, + "expiring": [], + "expiringCount": 0, + "online": [ + "alice@example.com" + ], + "onlineCount": 1, + "total": 2000 + }, "FallbackParentInfo": { "masterId": 0, "path": "" @@ -586,11 +672,38 @@ export const EXAMPLES: Record = { "wgMtu": 0, "wgPublicKey": "" }, + "InboundTrafficSummary": { + "down": 2097152, + "enable": true, + "id": 1, + "total": 10737418240, + "up": 1048576 + }, + "LogEntry": { + "DateTime": "2025-01-01T12:00:00Z", + "Email": "alice@example.com", + "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 = { "strategy": "random", "updatedAt": 1710000000000 }, + "Traffic": { + "Down": 2097152, + "IsInbound": true, + "IsOutbound": false, + "Tag": "inbound-443", + "Up": 1048576 + }, "User": { "id": 0, "password": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index e422e69db..54d393714 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1245,6 +1245,56 @@ export const SCHEMAS: Record = { ], "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 = { ], "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": "alice@example.com", + "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 = { ], "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": [ + "bob@example.com" + ], + "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": [ + "alice@example.com" + ], + "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 = { ], "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": "alice@example.com", + "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 = { ], "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 = { ], "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": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 84a3ec62a..966e46aba 100644 --- a/frontend/src/generated/types.ts +++ b/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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 79b1ae646..2c56200ca 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -321,6 +321,17 @@ export const ClientInboundSchema = z.object({ }); export type ClientInbound = z.infer; +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; + 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; +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; + 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; +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; + 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; +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; + +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; + +export const MLDSA65ResponseSchema = z.object({ + seed: z.string(), + verify: z.string(), +}); +export type MLDSA65Response = z.infer; + +export const MLKEM768ResponseSchema = z.object({ + client: z.string(), + seed: z.string(), +}); +export type MLKEM768Response = z.infer; + 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; +export const NewUUIDResponseSchema = z.object({ + uuid: z.string(), +}); +export type NewUUIDResponse = z.infer; + 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; +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; + export const UserSchema = z.object({ id: z.number().int(), password: z.string(), diff --git a/frontend/src/pages/api-docs/ApiDocsPage.css b/frontend/src/pages/api-docs/ApiDocsPage.css index 1a6ba20f1..6e5450f83 100644 --- a/frontend/src/pages/api-docs/ApiDocsPage.css +++ b/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; } diff --git a/frontend/src/pages/api-docs/ApiDocsPage.tsx b/frontend/src/pages/api-docs/ApiDocsPage.tsx index 960a0ca53..204e2c443 100644 --- a/frontend/src/pages/api-docs/ApiDocsPage.tsx +++ b/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() { +
+ + WebSocket events + + + After the cookie-authenticated GET /ws{' '} + upgrade, every server message uses{' '} + {'{ type, payload, time }'}. The time value + is Unix milliseconds. + + + {websocketEvents.map((event) => ( + + {event.type}}> + {event.summary} +
{JSON.stringify(event.example, null, 2)}
+
+ + ))} +
+
; + responses?: Record>; + security?: readonly Record[]; } 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 GET /panel/api/server/status.', - 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: [] }], }, ], }, diff --git a/frontend/src/pages/api-docs/websocket-events.ts b/frontend/src/pages/api-docs/websocket-events.ts new file mode 100644 index 000000000..997d93449 --- /dev/null +++ b/frontend/src/pages/api-docs/websocket-events.ts @@ -0,0 +1,325 @@ +export interface WebSocketEventDoc { + type: string; + summary: string; + payloadSchema: Record; + 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, +): 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 }, + }, + ]; +} diff --git a/frontend/src/test/openapi-runtime-contracts.test.ts b/frontend/src/test/openapi-runtime-contracts.test.ts new file mode 100644 index 000000000..ecfa3cd2b --- /dev/null +++ b/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; + 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; + } + >; + security?: Record[]; + 'x-websocket-events'?: WebSocketEventDoc[]; +} + +interface OpenApiSpec { + paths: Record>; + components: { schemas: Record }; +} + +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(); + }); +}); diff --git a/internal/web/routes_contract_test.go b/internal/web/routes_contract_test.go index 2c6ea464d..dd7329985 100644 --- a/internal/web/routes_contract_test.go +++ b/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 { diff --git a/internal/web/service/client_paging.go b/internal/web/service/client_paging.go index c7ff0e0e5..06fac9dd8 100644 --- a/internal/web/service/client_paging.go +++ b/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:"alice@example.com"` + 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:"[\"alice@example.com\"]"` + Depleted []string `json:"depleted" example:"[]"` + Expiring []string `json:"expiring" example:"[]"` + Deactive []string `json:"deactive" example:"[\"bob@example.com\"]"` } const ( diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index 2893da3a4..00812de50 100644 --- a/internal/web/service/inbound_traffic.go +++ b/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) { diff --git a/internal/web/service/server.go b/internal/web/service/server.go index e724752e1..43775bb8c 100644 --- a/internal/web/service/server.go +++ b/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:"alice@example.com"` + 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 diff --git a/internal/xray/traffic.go b/internal/xray/traffic.go index 3547573cf..0a88c35db 100644 --- a/internal/xray/traffic.go +++ b/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"` } diff --git a/tools/openapigen/main.go b/tools/openapigen/main.go index c24a3c286..6df8ca04a 100644 --- a/tools/openapigen/main.go +++ b/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",