mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 09:36:05 +00:00
feat(panel): in-panel API documentation page
New /panel/api-docs route with a one-page reference covering every /panel/api/* endpoint (Auth, Inbounds, Server, Nodes, Custom Geo, Backup) plus a Bearer-token primer that reads the current token and exposes Show/Copy/Regenerate inline. Sidebar gets an API Docs entry right after Xray; the menu label is shared via menu.apiDocs across all 13 locales.
This commit is contained in:
parent
7214ffafc5
commit
e642f7324e
22 changed files with 1113 additions and 89 deletions
13
frontend/api-docs.html
Normal file
13
frontend/api-docs.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · API Docs</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/api-docs.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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 <a-icon :type="x"> 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() {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Pin the desktop sider to the viewport. Without this, AD-Vue's
|
||||
* `<a-layout-sider>` stretches to match the flex row's height — which
|
||||
* equals the page height on tall dashboards (cards stack into one
|
||||
* column below `lg` = 992px), so the bottom-anchored
|
||||
* `.ant-layout-sider-trigger` (and Logout right above it) slide off
|
||||
* the screen. Sticky + 100vh keeps the sider exactly viewport-tall;
|
||||
* `align-self: flex-start` stops the flex row from re-stretching it. */
|
||||
.ant-sidebar>.ant-layout-sider {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
|
@ -226,12 +194,6 @@ function cycleTheme() {
|
|||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* `.sider-brand` and `.drawer-brand` share the same light-theme colour
|
||||
* but differ in layout — the sider one is centered with its own
|
||||
* top-of-sidebar padding + border, the drawer one sits inside a flex
|
||||
* header next to the close button. Dark/ultra colour overrides live
|
||||
* in the non-scoped block at the bottom (theme classes attach to
|
||||
* body / html). */
|
||||
.sider-brand,
|
||||
.drawer-brand {
|
||||
font-weight: 600;
|
||||
|
|
@ -359,31 +321,15 @@ function cycleTheme() {
|
|||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Push the utility (Logout) block to the bottom of the flex-column
|
||||
* drawer body and separate it from the nav block with a hairline. The
|
||||
* border colour is theme-neutral so it reads on both light and dark. */
|
||||
.drawer-utility {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
/* Pin Logout exactly above AD-Vue's `.ant-layout-sider-trigger` (the
|
||||
* collapse bar at the bottom, position: absolute; height: 48px). The
|
||||
* old `margin-top: auto` approach only pushed the utility down when the
|
||||
* content was shorter than the container — on short viewports the
|
||||
* Logout got hidden behind the trigger. Switching to a flex layout
|
||||
* where `.sider-nav` consumes all spare space (flex: 1) and
|
||||
* `.sider-utility` stays at content height pins it consistently. The
|
||||
* padding-bottom: 48px on the parent reserves the trigger's strip so
|
||||
* Logout sits directly above it.
|
||||
*
|
||||
* The mobile @media rule below still hides the whole sider on phones;
|
||||
* this block only kicks in once that override no longer matches. */
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
|
|
@ -407,9 +353,6 @@ function cycleTheme() {
|
|||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* On mobile the drawer is the menu — hide the inline sider's content
|
||||
* + the collapse trigger so the sider stops taking layout space and
|
||||
* leaves no remnant button next to the page. */
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
|
||||
display: none;
|
||||
|
|
@ -425,13 +368,6 @@ function cycleTheme() {
|
|||
</style>
|
||||
|
||||
<style>
|
||||
/* Non-scoped so the rules survive AD-Vue teleporting the drawer body
|
||||
* outside the AppSidebar element's scope id. Without this the Vue
|
||||
* `:global(body.dark) .drawer-brand` form did not produce the expected
|
||||
* `body.dark .drawer-brand[data-v-xxx]` selector reliably, and the
|
||||
* drawer brand stayed at the light-theme dark colour on the navy
|
||||
* drawer surface. Class names are specific enough that no collision is
|
||||
* expected; AppSidebar owns the only drawer in the app. */
|
||||
body.dark .drawer-brand,
|
||||
body.dark .sider-brand {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
|
|
@ -450,11 +386,6 @@ html[data-theme='ultra-dark'] .drawer-close {
|
|||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* Force a visible icon colour on the theme cycle button across themes.
|
||||
* The scoped `color: inherit` previously relied on parent chain to
|
||||
* cascade — fine on the desktop sider where `.sider-brand` is themed,
|
||||
* but inside the teleported drawer body the cascade didn't reach and
|
||||
* the icon merged into the dark background on mobile. */
|
||||
body.dark .theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
|
@ -463,14 +394,6 @@ html[data-theme='ultra-dark'] .theme-cycle {
|
|||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
/* Pin the drawer surface to the same colour the desktop sider uses
|
||||
* (Layout.colorBgHeader / Menu.colorItemBg from useTheme.js) so the
|
||||
* header, empty body region, and menu items read as one continuous
|
||||
* panel. AD-Vue's CSS-in-JS tokens otherwise leave the drawer at
|
||||
* colorBgElevated (#2d2d30 in dark) which clashes with the #252526
|
||||
* menu rows. `!important` is required to beat the CSS-in-JS rule
|
||||
* specificity; AppSidebar owns the only drawer in the app so this
|
||||
* doesn't collide with anything else. */
|
||||
body.dark .ant-drawer .ant-drawer-content,
|
||||
body.dark .ant-drawer .ant-drawer-body {
|
||||
background: #252526 !important;
|
||||
|
|
@ -481,14 +404,6 @@ html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
|
|||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
/* Force the same light-blue tint on selected + hover/active across
|
||||
* all three themes. AD-Vue's defaults read too subtle on the dark
|
||||
* sider, and the light-theme variant looked inconsistent vs. dark —
|
||||
* applying the same RGBA tint over all backgrounds gives the active
|
||||
* page the same visual weight everywhere. `!important` is required to
|
||||
* beat AD-Vue's CSS-in-JS specificity; scoped to .sider-nav /
|
||||
* .sider-utility / .drawer-menu so only the navigation menus pick up
|
||||
* the override (other a-menu instances keep AD-Vue defaults). */
|
||||
.sider-nav .ant-menu-item-selected,
|
||||
.sider-utility .ant-menu-item-selected,
|
||||
.drawer-menu .ant-menu-item-selected {
|
||||
|
|
|
|||
17
frontend/src/entries/api-docs.js
Normal file
17
frontend/src/entries/api-docs.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
|
||||
339
frontend/src/pages/api-docs/ApiDocsPage.vue
Normal file
339
frontend/src/pages/api-docs/ApiDocsPage.vue
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
KeyOutlined,
|
||||
ReloadOutlined,
|
||||
CopyOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import { HttpUtil, ClipboardManager } from '@/utils/index.js';
|
||||
import { sections } from './endpoints.js';
|
||||
import EndpointSection from './EndpointSection.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
const apiToken = ref('');
|
||||
const tokenLoading = ref(false);
|
||||
const tokenRotating = ref(false);
|
||||
const tokenVisible = ref(false);
|
||||
|
||||
const curlExample = `curl -X GET \\
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \\
|
||||
-H "Accept: application/json" \\
|
||||
https://your-panel.example.com/panel/api/inbounds/list`;
|
||||
|
||||
async function loadApiToken() {
|
||||
tokenLoading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/getApiToken');
|
||||
if (msg?.success) apiToken.value = msg.obj || '';
|
||||
} finally {
|
||||
tokenLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function regenerateApiToken() {
|
||||
Modal.confirm({
|
||||
title: t('pages.nodes.regenerateConfirm'),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
tokenRotating.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
|
||||
if (msg?.success) {
|
||||
apiToken.value = msg.obj || '';
|
||||
message.success(t('success'));
|
||||
}
|
||||
} finally {
|
||||
tokenRotating.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function copyApiToken() {
|
||||
if (!apiToken.value) return;
|
||||
const ok = await ClipboardManager.copy(apiToken.value);
|
||||
if (ok) message.success(t('success'));
|
||||
}
|
||||
|
||||
function scrollToSection(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadApiToken();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="api-docs-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
<a-layout-content class="content-area">
|
||||
<div class="docs-wrapper">
|
||||
<header class="docs-header">
|
||||
<h1 class="docs-title">API Documentation</h1>
|
||||
<p class="docs-lead">
|
||||
The 3x-ui panel exposes a REST API under <code>/panel/api/</code>. Authenticate with the panel session
|
||||
cookie, or with the <code>Authorization: Bearer <token></code> header below. Every endpoint
|
||||
returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<a-card class="token-card" size="small">
|
||||
<div class="token-card-head">
|
||||
<div class="token-card-title">
|
||||
<KeyOutlined />
|
||||
<span>API Token</span>
|
||||
</div>
|
||||
<a-space size="small" wrap>
|
||||
<a-button size="small" @click="tokenVisible = !tokenVisible">
|
||||
<template #icon>
|
||||
<EyeInvisibleOutlined v-if="tokenVisible" />
|
||||
<EyeOutlined v-else />
|
||||
</template>
|
||||
{{ tokenVisible ? 'Hide' : 'Show' }}
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="!apiToken" @click="copyApiToken">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
Copy
|
||||
</a-button>
|
||||
<a-button size="small" danger :loading="tokenRotating" @click="regenerateApiToken">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
Regenerate
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<a-spin :spinning="tokenLoading" size="small">
|
||||
<pre
|
||||
class="token-value">{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}</pre>
|
||||
</a-spin>
|
||||
<p class="token-hint">
|
||||
Send it on every request as <code>Authorization: Bearer <token></code>. Token-authenticated
|
||||
callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately —
|
||||
running bots will need the new value.
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
<a-card class="curl-card" size="small" title="Quick example">
|
||||
<pre class="code-block">{{ curlExample }}</pre>
|
||||
</a-card>
|
||||
|
||||
<nav class="toc-nav">
|
||||
<span class="toc-label">On this page:</span>
|
||||
<a v-for="s in sections" :key="s.id" class="toc-link" :href="`#${s.id}`"
|
||||
@click.prevent="scrollToSection(s.id)">
|
||||
{{ s.title }}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<EndpointSection v-for="s in sections" :key="s.id" :section="s" />
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-docs-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #0a0a0a;
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 24px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 16px 12px 12px;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-wrapper {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.docs-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.docs-lead {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.docs-lead code,
|
||||
.token-hint code {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.token-card,
|
||||
.curl-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.token-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.token-card-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.token-hint {
|
||||
margin: 10px 0 0;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.toc-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 14px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toc-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.toc-link {
|
||||
color: #1677ff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toc-link:hover {
|
||||
color: #4096ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .docs-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .docs-lead,
|
||||
body.dark .token-hint {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.dark .docs-lead code,
|
||||
body.dark .token-hint code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body.dark .token-value,
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .toc-nav {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
body.dark .toc-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
128
frontend/src/pages/api-docs/EndpointRow.vue
Normal file
128
frontend/src/pages/api-docs/EndpointRow.vue
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { methodColors } from './endpoints.js';
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const tagColor = computed(() => methodColors[props.endpoint.method] || 'default');
|
||||
const hasParams = computed(() => Array.isArray(props.endpoint.params) && props.endpoint.params.length > 0);
|
||||
|
||||
const paramColumns = [
|
||||
{ title: 'Name', dataIndex: 'name', key: 'name', width: 180 },
|
||||
{ title: 'In', dataIndex: 'in', key: 'in', width: 100 },
|
||||
{ title: 'Type', dataIndex: 'type', key: 'type', width: 120 },
|
||||
{ title: 'Description', dataIndex: 'desc', key: 'desc' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="endpoint-row">
|
||||
<div class="endpoint-header">
|
||||
<a-tag :color="tagColor" class="method-tag">{{ endpoint.method }}</a-tag>
|
||||
<code class="endpoint-path">{{ endpoint.path }}</code>
|
||||
</div>
|
||||
|
||||
<p v-if="endpoint.summary" class="endpoint-summary">{{ endpoint.summary }}</p>
|
||||
|
||||
<div v-if="hasParams" class="endpoint-block">
|
||||
<div class="block-label">Parameters</div>
|
||||
<a-table :columns="paramColumns" :data-source="endpoint.params" :pagination="false" size="small" row-key="name" />
|
||||
</div>
|
||||
|
||||
<div v-if="endpoint.body" class="endpoint-block">
|
||||
<div class="block-label">Request body</div>
|
||||
<a-typography-paragraph :copyable="{ text: endpoint.body }">
|
||||
<pre class="code-block">{{ endpoint.body }}</pre>
|
||||
</a-typography-paragraph>
|
||||
</div>
|
||||
|
||||
<div v-if="endpoint.response" class="endpoint-block">
|
||||
<div class="block-label">Response</div>
|
||||
<a-typography-paragraph :copyable="{ text: endpoint.response }">
|
||||
<pre class="code-block">{{ endpoint.response }}</pre>
|
||||
</a-typography-paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.endpoint-row {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.endpoint-row + .endpoint-row {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.method-tag {
|
||||
font-weight: 600;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.endpoint-path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.endpoint-summary {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.endpoint-block {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .endpoint-summary {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.dark .block-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
</style>
|
||||
65
frontend/src/pages/api-docs/EndpointSection.vue
Normal file
65
frontend/src/pages/api-docs/EndpointSection.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<script setup>
|
||||
import EndpointRow from './EndpointRow.vue';
|
||||
|
||||
defineProps({
|
||||
section: { type: Object, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :id="section.id" class="api-section">
|
||||
<h2 class="section-title">{{ section.title }}</h2>
|
||||
<p v-if="section.description" class="section-description">{{ section.description }}</p>
|
||||
<div class="endpoints">
|
||||
<EndpointRow v-for="(endpoint, idx) in section.endpoints" :key="idx" :endpoint="endpoint" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-section {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
scroll-margin-top: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 6px 0 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.endpoints > :first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .api-section {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .api-section {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.dark .section-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .section-description {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
</style>
|
||||
525
frontend/src/pages/api-docs/endpoints.js
Normal file
525
frontend/src/pages/api-docs/endpoints.js
Normal file
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 <meta name="csrf-token">,
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"nodes": "النودز",
|
||||
"settings": "إعدادات البانل",
|
||||
"xray": "إعدادات Xray",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "تسجيل خروج",
|
||||
"link": "إدارة"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"nodes": "Nodes",
|
||||
"settings": "Panel Settings",
|
||||
"xray": "Xray Configs",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Log Out",
|
||||
"link": "Manage"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"nodes": "Nodos",
|
||||
"settings": "Configuraciones",
|
||||
"xray": "Ajustes Xray",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Cerrar Sesión",
|
||||
"link": "Gestionar"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"nodes": "نودها",
|
||||
"settings": "تنظیمات پنل",
|
||||
"xray": "پیکربندی ایکسری",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "خروج",
|
||||
"link": "مدیریت"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"nodes": "Node",
|
||||
"settings": "Pengaturan Panel",
|
||||
"xray": "Konfigurasi Xray",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Keluar",
|
||||
"link": "Kelola"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"nodes": "ノード",
|
||||
"settings": "パネル設定",
|
||||
"xray": "Xray設定",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "ログアウト",
|
||||
"link": "リンク管理"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"nodes": "Nós",
|
||||
"settings": "Panel Settings",
|
||||
"xray": "Xray Configs",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Sair",
|
||||
"link": "Gerenciar"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"nodes": "Узлы",
|
||||
"settings": "Настройки",
|
||||
"xray": "Настройки Xray",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Выход",
|
||||
"link": "Управление"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"nodes": "Düğümler",
|
||||
"settings": "Panel Ayarları",
|
||||
"xray": "Xray Yapılandırmaları",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Çıkış Yap",
|
||||
"link": "Yönet"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"nodes": "Вузли",
|
||||
"settings": "Параметри панелі",
|
||||
"xray": "Конфігурації Xray",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Вийти",
|
||||
"link": "Керувати"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"nodes": "节点",
|
||||
"settings": "面板设置",
|
||||
"xray": "Xray 设置",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "退出登录",
|
||||
"link": "管理"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"nodes": "節點",
|
||||
"settings": "面板設定",
|
||||
"xray": "Xray 設定",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "退出登入",
|
||||
"link": "管理"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue