diff --git a/frontend/api-docs.html b/frontend/api-docs.html
new file mode 100644
index 00000000..9f35080a
--- /dev/null
+++ b/frontend/api-docs.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ 3x-ui · API Docs
+
+
+
+
+
+
+
diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue
index 8152a242..7763b715 100644
--- a/frontend/src/components/AppSidebar.vue
+++ b/frontend/src/components/AppSidebar.vue
@@ -10,6 +10,7 @@ import {
LogoutOutlined,
CloseOutlined,
MenuOutlined,
+ ApiOutlined,
} from '@ant-design/icons-vue';
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
@@ -19,17 +20,12 @@ const { t } = useI18n();
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const props = defineProps({
- // Path prefix (e.g. /custom-base/) the panel is served under. Defaults
- // to '' which means tab keys end up as '/panel/...'. Pages pass the
- // value the Go backend gave them (in production via a meta tag).
basePath: { type: String, default: '' },
// Current request URI so the matching menu item highlights.
requestUri: { type: String, default: '' },
});
-// AD-Vue 4 dropped in favor of explicit icon
-// imports — keep a small name-to-component map so tab definitions stay
-// declarative.
+
const iconByName = {
dashboard: DashboardOutlined,
user: UserOutlined,
@@ -37,41 +33,26 @@ const iconByName = {
tool: ToolOutlined,
cluster: ClusterOutlined,
logout: LogoutOutlined,
+ apidocs: ApiOutlined,
};
-// basePath comes from Go (`/` by default, `/myprefix/` when configured) so
-// these concatenations land on absolute paths. In dev we synthesize the prop
-// from a window global which can be empty — force a leading slash so the
-// browser doesn't resolve the link relative to the current pathname (which
-// would turn /panel/settings + 'panel/...' into /panel/panel/...).
const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
-// Labels are i18n-driven so the sidebar matches the locale picked
-// in panel settings without a page reload of the sidebar component.
const tabs = computed(() => [
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
+ { key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
]);
-// Logout sits in its own pinned-to-bottom block on the drawer; the
-// remaining items are the navigation proper. The full-height sider on
-// desktop still uses `tabs` as-is so the desktop look is unchanged.
const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
const utilTabs = computed(() => tabs.value.filter((tab) => tab.icon === 'logout'));
-
const activeTab = ref([props.requestUri]);
-
const drawerOpen = ref(false);
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
-
-// Drawer width is capped against the viewport — AD-Vue's default 378px
-// overflows on narrow phones (e.g. 360px portrait), leaving the page
-// hidden behind the mask. `min()` keeps it sane on both phones and
-// tablets while never exceeding 320px on larger displays.
const drawerWidth = 'min(82vw, 320px)';
function openLink(key) {
@@ -98,12 +79,6 @@ function closeDrawer() {
drawerOpen.value = false;
}
-/* 3-state theme cycle driven by the brand-row icon button.
- * Light → Dark (turn dark on, ensure ultra off)
- * Dark → Ultra (turn ultra on)
- * Ultra → Light (turn ultra off, turn dark off)
- * Using a single button keeps the sider header clean — the old
- * ThemeSwitch a-sub-menu plus its expandable items lived here. */
function cycleTheme() {
pauseAnimationsUntilLeave('theme-cycle');
if (!theme.isDark) {
@@ -212,13 +187,6 @@ function cycleTheme() {
+
+
diff --git a/frontend/src/pages/api-docs/EndpointRow.vue b/frontend/src/pages/api-docs/EndpointRow.vue
new file mode 100644
index 00000000..0b7fb300
--- /dev/null
+++ b/frontend/src/pages/api-docs/EndpointRow.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
{{ endpoint.summary }}
+
+
+
+
+
Request body
+
+ {{ endpoint.body }}
+
+
+
+
+
Response
+
+ {{ endpoint.response }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/api-docs/EndpointSection.vue b/frontend/src/pages/api-docs/EndpointSection.vue
new file mode 100644
index 00000000..a015795e
--- /dev/null
+++ b/frontend/src/pages/api-docs/EndpointSection.vue
@@ -0,0 +1,65 @@
+
+
+
+
+ {{ section.title }}
+ {{ section.description }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js
new file mode 100644
index 00000000..47929738
--- /dev/null
+++ b/frontend/src/pages/api-docs/endpoints.js
@@ -0,0 +1,525 @@
+export const sections = [
+ {
+ id: 'auth',
+ title: 'Authentication',
+ description:
+ 'Two authentication modes are supported. UI sessions use a cookie set by the login endpoint. Programmatic clients (bots, scripts, remote panels) authenticate with a Bearer token taken from Settings → Security → API Token. Both work for every endpoint under /panel/api/*.',
+ endpoints: [
+ {
+ method: 'POST',
+ path: '/login',
+ summary: 'Authenticate with username + password and receive a session cookie. Required before any cookie-based API call.',
+ params: [
+ { name: 'username', in: 'body', type: 'string', desc: 'Panel admin username.' },
+ { name: 'password', in: 'body', type: 'string', desc: 'Panel admin password.' },
+ { name: 'twoFactorCode', in: 'body', type: 'string', desc: 'OTP code when 2FA is enabled. Omit otherwise.' },
+ ],
+ body: '{\n "username": "admin",\n "password": "admin",\n "twoFactorCode": "123456"\n}',
+ response:
+ '{\n "success": true,\n "msg": "Logged in successfully"\n}',
+ },
+ {
+ method: 'GET',
+ path: '/logout',
+ summary: 'Clear the session cookie. Redirects back to the login page; not useful from non-browser clients.',
+ },
+ {
+ method: 'GET',
+ path: '/csrf-token',
+ summary: 'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.',
+ response:
+ '{\n "success": true,\n "obj": "csrf-token-string"\n}',
+ },
+ {
+ method: 'POST',
+ path: '/getTwoFactorEnable',
+ summary: 'Returns whether 2FA is enabled on the panel — used by the login page to decide whether to show the OTP field.',
+ response: '{\n "success": true,\n "obj": false\n}',
+ },
+ ],
+ },
+
+ {
+ id: 'inbounds',
+ title: 'Inbounds API',
+ description:
+ 'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token.',
+ endpoints: [
+ {
+ method: 'GET',
+ path: '/panel/api/inbounds/list',
+ summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters.',
+ response:
+ '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": "{\\"clients\\":[...]}",\n "streamSettings": "{...}",\n "tag": "inbound-443",\n "sniffing": "{...}",\n "clientStats": [...]\n }\n ]\n}',
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/inbounds/get/:id',
+ summary: 'Fetch a single inbound by numeric ID.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+ ],
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/inbounds/getClientTraffics/:email',
+ summary: 'Traffic counters for a client identified by email.',
+ params: [
+ { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
+ ],
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/inbounds/getClientTrafficsById/:id',
+ summary: 'Traffic counters for a client identified by its UUID/password.',
+ params: [
+ { name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/add',
+ summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).',
+ body:
+ '{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/del/:id',
+ summary: 'Delete an inbound by ID. Also removes its associated client stats rows.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/update/:id',
+ summary: 'Replace an inbound’s configuration. Body shape mirrors /add. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/setEnable/:id',
+ summary: 'Toggle only the enable flag without serialising the whole settings JSON. Recommended for UI switches on large inbounds.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+ ],
+ body: '{\n "enable": false\n}',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/clientIps/:email',
+ summary: 'List source IPs that have connected with the given client’s credentials. Returns an array of "ip (timestamp)" strings.',
+ params: [
+ { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/clearClientIps/:email',
+ summary: 'Reset the recorded IP list for a client.',
+ params: [
+ { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/addClient',
+ summary: 'Add one or more clients to an existing inbound. The settings field is the JSON-encoded settings.clients array of the target inbound.',
+ body:
+ '{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"newuser\\",\\"limitIp\\":0,\\"totalGB\\":0,\\"expiryTime\\":0,\\"enable\\":true,\\"flow\\":\\"\\"}]}"\n}',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/:id/copyClients',
+ summary: 'Copy selected clients from one inbound into another. Useful for duplicating user lists across protocols.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Target inbound ID.' },
+ { name: 'sourceInboundId', in: 'body', type: 'number', desc: 'Inbound ID to read clients from.' },
+ { name: 'clientEmails', in: 'body', type: 'string[]', desc: 'Emails of clients to copy. Empty means all clients.' },
+ { name: 'flow', in: 'body', type: 'string', desc: 'Override the flow field on copied clients (e.g. "xtls-rprx-vision"). Empty to keep source flow.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/:id/delClient/:clientId',
+ summary: 'Delete a client by its UUID/password from a specific inbound.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+ { name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/updateClient/:clientId',
+ summary: 'Update a single client without rewriting the whole settings JSON. Send the target inbound payload with the new client values.',
+ params: [
+ { name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
+ ],
+ body:
+ '{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
+ summary: 'Zero out upload + download counters for one client.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+ { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/resetAllTraffics',
+ summary: 'Reset upload + download counters on every inbound. Destructive — accounting history is lost.',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/resetAllClientTraffics/:id',
+ summary: 'Reset traffic for every client in one inbound.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/delDepletedClients/:id',
+ summary: 'Delete clients in this inbound whose traffic cap or expiry has elapsed. Pass id=-1 to sweep every inbound.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID, or -1 for all inbounds.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/import',
+ summary: 'Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single "data" field.',
+ params: [
+ { name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/onlines',
+ summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
+ response: '{\n "success": true,\n "obj": ["user1", "user2"]\n}',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/lastOnline',
+ summary: 'Map of client email → last-seen unix timestamp.',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/updateClientTraffic/:email',
+ summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.',
+ params: [
+ { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+ ],
+ body: '{\n "upload": 1073741824,\n "download": 5368709120\n}',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/:id/delClientByEmail/:email',
+ summary: 'Delete a client identified by email rather than UUID.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+ { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+ ],
+ },
+ ],
+ },
+
+ {
+ id: 'server',
+ title: 'Server API',
+ description:
+ 'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
+ endpoints: [
+ {
+ method: 'GET',
+ path: '/panel/api/server/status',
+ summary: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.',
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/server/cpuHistory/:bucket',
+ summary: 'Legacy: aggregated CPU history. Use /history/cpu/:bucket instead — same data with a uniform {t, v} shape.',
+ params: [
+ { name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
+ ],
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/server/history/:metric/:bucket',
+ summary: 'Aggregated time-series for one metric. Returns an array of {t, v} samples covering the last ~6 hours.',
+ params: [
+ { name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem | swap | netIn | netOut | tcpCount | udpCount | load1 | online.' },
+ { name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
+ ],
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/server/getXrayVersion',
+ summary: 'List Xray binary versions available for install on this host.',
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/server/getPanelUpdateInfo',
+ summary: 'Check whether a newer 3x-ui release is available on GitHub.',
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/server/getConfigJson',
+ summary: 'Return the assembled Xray config that’s currently running on this host.',
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/server/getDb',
+ summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.',
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/server/getNewUUID',
+ summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.',
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/server/getNewX25519Cert',
+ summary: 'Generate a new X25519 keypair for Reality.',
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/server/getNewmldsa65',
+ summary: 'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.',
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/server/getNewmlkem768',
+ summary: 'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.',
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/server/getNewVlessEnc',
+ summary: 'Generate a new VLESS encryption keypair.',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/server/stopXrayService',
+ summary: 'Stop the Xray binary. All proxies go offline immediately.',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/server/restartXrayService',
+ summary: 'Reload Xray with the current config. Typically required after structural inbound or routing changes.',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/server/installXray/:version',
+ summary: 'Download and install the specified Xray version. Pass "latest" for the newest release.',
+ params: [
+ { name: 'version', in: 'path', type: 'string', desc: 'Xray tag (e.g. v25.10.31) or "latest".' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/server/updatePanel',
+ summary: 'Self-update the panel to the latest version. The server restarts on success.',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/server/updateGeofile',
+ summary: 'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/server/updateGeofile/:fileName',
+ summary: 'Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).',
+ params: [
+ { name: 'fileName', in: 'path', type: 'string', desc: 'Filename of the data file to refresh.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/server/logs/:count',
+ summary: 'Return the last N lines of the panel’s own log.',
+ params: [
+ { name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
+ ],
+ body: '{\n "level": "info",\n "syslog": false\n}',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/server/xraylogs/:count',
+ summary: 'Return the last N lines of the Xray process log.',
+ params: [
+ { name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/server/importDB',
+ summary: 'Restore the panel DB from an uploaded SQLite file (multipart form, field name "db"). The panel restarts after restore. Destructive.',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/server/getNewEchCert',
+ summary: 'Generate a new ECH (Encrypted Client Hello) keypair. Body picks the algorithm.',
+ },
+ ],
+ },
+
+ {
+ id: 'nodes',
+ title: 'Nodes API',
+ description:
+ 'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.',
+ endpoints: [
+ {
+ method: 'GET',
+ path: '/panel/api/nodes/list',
+ summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/nodes/get/:id',
+ summary: 'Fetch a single node by ID.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/nodes/add',
+ summary: 'Register a new remote node. Provide its URL, apiToken, and optional label/notes.',
+ body:
+ '{\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/nodes/update/:id',
+ summary: 'Replace a node’s connection details. Same body shape as /add.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/nodes/del/:id',
+ summary: 'Delete a node. Inbounds bound to it are not auto-migrated.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/nodes/setEnable/:id',
+ summary: 'Pause or resume traffic sync with this node.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
+ ],
+ body: '{\n "enable": true\n}',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/nodes/test',
+ summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/nodes/probe/:id',
+ summary: 'Probe an existing node, updating its cached health state.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
+ ],
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/nodes/history/:id/:metric/:bucket',
+ summary: 'Aggregated metric history for a node — same shape as /server/history, scoped to one node.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
+ { name: 'metric', in: 'path', type: 'string', desc: 'Metric key (cpu, mem, netIn, …).' },
+ { name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds.' },
+ ],
+ },
+ ],
+ },
+
+ {
+ id: 'customGeo',
+ title: 'Custom Geo API',
+ description:
+ 'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
+ endpoints: [
+ {
+ method: 'GET',
+ path: '/panel/api/custom-geo/list',
+ summary: 'List configured custom geo sources with their type, alias, URL, status, and last-download timestamp.',
+ },
+ {
+ method: 'GET',
+ path: '/panel/api/custom-geo/aliases',
+ summary: 'List geo aliases currently usable in routing rules — both built-in defaults and the user-configured ones.',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/custom-geo/add',
+ summary: 'Register a custom geo source. Alias is auto-normalised; URL must point to a .dat / .json blob.',
+ body:
+ '{\n "type": "geoip",\n "alias": "myips",\n "url": "https://example.com/geo/my.dat"\n}',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/custom-geo/update/:id',
+ summary: 'Replace a custom geo source. Same body shape as /add.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/custom-geo/delete/:id',
+ summary: 'Remove a custom geo source and its cached file.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/custom-geo/download/:id',
+ summary: 'Re-download one custom geo source on demand.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
+ ],
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/custom-geo/update-all',
+ summary: 'Re-download every configured custom geo source. Errors are reported per-source in the response.',
+ },
+ ],
+ },
+
+ {
+ id: 'backup',
+ title: 'Backup',
+ description: 'Operations that interact with the configured Telegram bot.',
+ endpoints: [
+ {
+ method: 'GET',
+ path: '/panel/api/backuptotgbot',
+ summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.',
+ },
+ ],
+ },
+];
+
+export const methodColors = {
+ GET: 'blue',
+ POST: 'green',
+ PUT: 'orange',
+ PATCH: 'orange',
+ DELETE: 'red',
+};
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index da0a1ba2..91d42c19 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -26,6 +26,8 @@ const BASE_MIGRATED_ROUTES = {
'panel/xray/': '/xray.html',
'panel/nodes': '/nodes.html',
'panel/nodes/': '/nodes.html',
+ 'panel/api-docs': '/api-docs.html',
+ 'panel/api-docs/': '/api-docs.html',
};
let cachedBasePath = '/';
@@ -150,6 +152,7 @@ export default defineConfig({
inbounds: path.resolve(__dirname, 'inbounds.html'),
xray: path.resolve(__dirname, 'xray.html'),
nodes: path.resolve(__dirname, 'nodes.html'),
+ apiDocs: path.resolve(__dirname, 'api-docs.html'),
subpage: path.resolve(__dirname, 'subpage.html'),
},
output: {
diff --git a/web/controller/xui.go b/web/controller/xui.go
index d385cd50..2fcf346b 100644
--- a/web/controller/xui.go
+++ b/web/controller/xui.go
@@ -36,6 +36,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/nodes", a.nodes)
g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings)
+ g.GET("/api-docs", a.apiDocs)
// SPA pages built by Vite don't have a server-rendered ,
// so they fetch the session token via this endpoint at startup and replay it
@@ -76,6 +77,11 @@ func (a *XUIController) xraySettings(c *gin.Context) {
serveDistPage(c, "xray.html")
}
+// apiDocs renders the in-panel API documentation page.
+func (a *XUIController) apiDocs(c *gin.Context) {
+ serveDistPage(c, "api-docs.html")
+}
+
// csrfToken returns the session CSRF token to authenticated SPA clients.
// The endpoint is GET (a safe method) so it bypasses CSRFMiddleware itself,
// but checkLogin still gates the response — anonymous callers get 401/redirect.
diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json
index c6e381e7..2cdc33a6 100644
--- a/web/translation/ar-EG.json
+++ b/web/translation/ar-EG.json
@@ -97,6 +97,7 @@
"nodes": "النودز",
"settings": "إعدادات البانل",
"xray": "إعدادات Xray",
+ "apiDocs": "API Docs",
"logout": "تسجيل خروج",
"link": "إدارة"
},
diff --git a/web/translation/en-US.json b/web/translation/en-US.json
index 04a32ba7..bd1f882f 100644
--- a/web/translation/en-US.json
+++ b/web/translation/en-US.json
@@ -97,6 +97,7 @@
"nodes": "Nodes",
"settings": "Panel Settings",
"xray": "Xray Configs",
+ "apiDocs": "API Docs",
"logout": "Log Out",
"link": "Manage"
},
diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json
index ce9d5c67..a351d192 100644
--- a/web/translation/es-ES.json
+++ b/web/translation/es-ES.json
@@ -97,6 +97,7 @@
"nodes": "Nodos",
"settings": "Configuraciones",
"xray": "Ajustes Xray",
+ "apiDocs": "API Docs",
"logout": "Cerrar Sesión",
"link": "Gestionar"
},
diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json
index eedb3826..c512ed29 100644
--- a/web/translation/fa-IR.json
+++ b/web/translation/fa-IR.json
@@ -97,6 +97,7 @@
"nodes": "نودها",
"settings": "تنظیمات پنل",
"xray": "پیکربندی ایکسری",
+ "apiDocs": "API Docs",
"logout": "خروج",
"link": "مدیریت"
},
diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json
index 0373b3a1..ce83d006 100644
--- a/web/translation/id-ID.json
+++ b/web/translation/id-ID.json
@@ -97,6 +97,7 @@
"nodes": "Node",
"settings": "Pengaturan Panel",
"xray": "Konfigurasi Xray",
+ "apiDocs": "API Docs",
"logout": "Keluar",
"link": "Kelola"
},
diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json
index 57c7af8f..073072a8 100644
--- a/web/translation/ja-JP.json
+++ b/web/translation/ja-JP.json
@@ -97,6 +97,7 @@
"nodes": "ノード",
"settings": "パネル設定",
"xray": "Xray設定",
+ "apiDocs": "API Docs",
"logout": "ログアウト",
"link": "リンク管理"
},
diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json
index f93215d7..d19506d0 100644
--- a/web/translation/pt-BR.json
+++ b/web/translation/pt-BR.json
@@ -97,6 +97,7 @@
"nodes": "Nós",
"settings": "Panel Settings",
"xray": "Xray Configs",
+ "apiDocs": "API Docs",
"logout": "Sair",
"link": "Gerenciar"
},
diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json
index 6d32f596..9bf9ddeb 100644
--- a/web/translation/ru-RU.json
+++ b/web/translation/ru-RU.json
@@ -97,6 +97,7 @@
"nodes": "Узлы",
"settings": "Настройки",
"xray": "Настройки Xray",
+ "apiDocs": "API Docs",
"logout": "Выход",
"link": "Управление"
},
diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json
index b9681101..fc1b8bfe 100644
--- a/web/translation/tr-TR.json
+++ b/web/translation/tr-TR.json
@@ -97,6 +97,7 @@
"nodes": "Düğümler",
"settings": "Panel Ayarları",
"xray": "Xray Yapılandırmaları",
+ "apiDocs": "API Docs",
"logout": "Çıkış Yap",
"link": "Yönet"
},
diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json
index 10fe2bd9..23df613b 100644
--- a/web/translation/uk-UA.json
+++ b/web/translation/uk-UA.json
@@ -97,6 +97,7 @@
"nodes": "Вузли",
"settings": "Параметри панелі",
"xray": "Конфігурації Xray",
+ "apiDocs": "API Docs",
"logout": "Вийти",
"link": "Керувати"
},
diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json
index 2dc5081e..56251525 100644
--- a/web/translation/vi-VN.json
+++ b/web/translation/vi-VN.json
@@ -98,6 +98,7 @@
"settings": "Cài đặt bảng điều khiển",
"logout": "Đăng xuất",
"xray": "Cài đặt Xray",
+ "apiDocs": "API Docs",
"link": "Quản lý"
},
"pages": {
diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json
index d9f7b562..07dbeff2 100644
--- a/web/translation/zh-CN.json
+++ b/web/translation/zh-CN.json
@@ -97,6 +97,7 @@
"nodes": "节点",
"settings": "面板设置",
"xray": "Xray 设置",
+ "apiDocs": "API Docs",
"logout": "退出登录",
"link": "管理"
},
diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json
index 84f2f053..09c34438 100644
--- a/web/translation/zh-TW.json
+++ b/web/translation/zh-TW.json
@@ -97,6 +97,7 @@
"nodes": "節點",
"settings": "面板設定",
"xray": "Xray 設定",
+ "apiDocs": "API Docs",
"logout": "退出登入",
"link": "管理"
},