From 01ce2bcecbdfc19dc5e9776d05e6370fbf413794 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Tue, 15 Sep 2026 23:01:15 +0200 Subject: [PATCH] feat(api-docs): split the API docs page into tabs The page stacked the WebSocket event cards above every Panel API operation in one long scroll. The WebSocket events and the 3X-UI Panel API now sit in separate tabs, and the Panel API shows one OpenAPI tag at a time through section tabs placed between the Authorize bar and the operations. The section tabs replace Swagger UI's FilterContainer and wrap the taggedOperations selector, so all sections share one Swagger instance and keep authorization and try-it-out state. Swagger's own filter matches tags by substring ("Settings" would also show "Xray Settings") and does nothing until set, so the wrapper matches the exact tag and defaults to the first. Tag names come from the loaded spec rather than importing endpoints.ts, which would have grown the page chunk from 23 kB to 119 kB. --- frontend/src/pages/api-docs/ApiDocsPage.css | 5 +- frontend/src/pages/api-docs/ApiDocsPage.tsx | 135 +++++++++++++++----- 2 files changed, 106 insertions(+), 34 deletions(-) diff --git a/frontend/src/pages/api-docs/ApiDocsPage.css b/frontend/src/pages/api-docs/ApiDocsPage.css index 6e5450f83..178f94795 100644 --- a/frontend/src/pages/api-docs/ApiDocsPage.css +++ b/frontend/src/pages/api-docs/ApiDocsPage.css @@ -45,15 +45,14 @@ } .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 .swagger-ui .section-tabs { + margin-top: 20px; } .api-docs-page .websocket-events pre { diff --git a/frontend/src/pages/api-docs/ApiDocsPage.tsx b/frontend/src/pages/api-docs/ApiDocsPage.tsx index 204e2c443..38e26e7d9 100644 --- a/frontend/src/pages/api-docs/ApiDocsPage.tsx +++ b/frontend/src/pages/api-docs/ApiDocsPage.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Card, Col, ConfigProvider, Layout, Row, Typography } from 'antd'; +import { Card, Col, ConfigProvider, Layout, Row, Tabs, Typography } from 'antd'; import SwaggerUI from 'swagger-ui-react'; import 'swagger-ui-react/swagger-ui.css'; @@ -14,6 +14,61 @@ const basePath = window.X_UI_BASE_PATH || ''; const openApiUrl = `${basePath}panel/api/openapi.json`; const websocketEvents = buildWebSocketEvents(EXAMPLES); +interface TaggedOperations { + keySeq: () => { first: () => string | undefined }; + filter: (keep: (operations: unknown, tag: string) => boolean) => TaggedOperations; +} + +interface LayoutSelectors { + currentFilter: () => string | false; +} + +interface SectionTabsProps { + specSelectors: { tags: () => { toJS: () => { name: string }[] } }; + layoutSelectors: LayoutSelectors; + layoutActions: { updateFilter: (tag: string) => void }; +} + +function SectionTabs({ specSelectors, layoutSelectors, layoutActions }: SectionTabsProps) { + const tags = specSelectors + .tags() + .toJS() + .map((tag) => tag.name); + return ( +
+ ({ key: tag, label: tag }))} + /> +
+ ); +} + +// Shows one tag at a time, the first until a tab is picked. Swagger's own filter is a +// substring match ("Settings" would also show "Xray Settings") and no-op while unset. +const sectionTabsPlugin = { + statePlugins: { + spec: { + wrapSelectors: { + taggedOperations: + ( + select: (...args: unknown[]) => TaggedOperations, + system: { getSystem: () => { layoutSelectors: LayoutSelectors } }, + ) => + (...args: unknown[]) => { + const operations = select(...args); + const active = + system.getSystem().layoutSelectors.currentFilter() || operations.keySeq().first(); + return operations.filter((_, tag) => tag === active); + }, + }, + }, + }, + components: { FilterContainer: SectionTabs }, +}; + export default function ApiDocsPage() { const { isDark, isUltra, antdThemeConfig } = useTheme(); const { t } = useTranslation(); @@ -32,36 +87,54 @@ 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)}
-
- - ))} -
-
-
- -
+ + + + ), + }, + { + key: 'websocket-events', + label: 'WebSocket events', + children: ( +
+ + 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)}
+
+ + ))} +
+
+ ), + }, + ]} + />