mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 05:04:22 +00:00
fix
This commit is contained in:
parent
34ea51f7b4
commit
26246ee810
5 changed files with 85 additions and 154 deletions
|
|
@ -33,9 +33,9 @@ const tokenVisible = ref(false);
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const collapsedSections = ref(new Set());
|
const collapsedSections = ref(new Set());
|
||||||
|
|
||||||
const curlExample = `curl -X GET \
|
const curlExample = `curl -X GET \\
|
||||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
-H "Authorization: Bearer YOUR_API_TOKEN" \\
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \\
|
||||||
https://your-panel.example.com/panel/api/inbounds/list`;
|
https://your-panel.example.com/panel/api/inbounds/list`;
|
||||||
|
|
||||||
const sections = computed(() => {
|
const sections = computed(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { methodColors } from './endpoints.js';
|
import { methodColors, safeInlineHtml } from './endpoints.js';
|
||||||
import CodeBlock from './CodeBlock.vue';
|
import CodeBlock from './CodeBlock.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|
@ -25,7 +25,7 @@ const paramColumns = [
|
||||||
<code class="endpoint-path">{{ endpoint.path }}</code>
|
<code class="endpoint-path">{{ endpoint.path }}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="endpoint.summary" class="endpoint-summary" v-html="endpoint.summary"></p>
|
<p v-if="endpoint.summary" class="endpoint-summary" v-html="safeInlineHtml(endpoint.summary)"></p>
|
||||||
|
|
||||||
<div v-if="hasParams" class="endpoint-block">
|
<div v-if="hasParams" class="endpoint-block">
|
||||||
<div class="block-label">Parameters</div>
|
<div class="block-label">Parameters</div>
|
||||||
|
|
@ -127,7 +127,7 @@ body.dark .block-label {
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .error-label {
|
body.dark .error-label {
|
||||||
color: rgba(255, 255, 255, 0.55);
|
color: #ff7b72;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .code-block {
|
body.dark .code-block {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import EndpointRow from './EndpointRow.vue';
|
import EndpointRow from './EndpointRow.vue';
|
||||||
|
import { safeInlineHtml } from './endpoints.js';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
section: { type: Object, required: true },
|
section: { type: Object, required: true },
|
||||||
|
|
@ -30,7 +31,7 @@ const endpointLabel = computed(() =>
|
||||||
</div>
|
</div>
|
||||||
<span class="endpoint-count">{{ endpointLabel }}</span>
|
<span class="endpoint-count">{{ endpointLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="section.description && !collapsed" class="section-description" v-html="section.description"></p>
|
<p v-if="section.description && !collapsed" class="section-description" v-html="safeInlineHtml(section.description)"></p>
|
||||||
|
|
||||||
<div v-if="section.subHeader && !collapsed" class="sub-header-block">
|
<div v-if="section.subHeader && !collapsed" class="sub-header-block">
|
||||||
<div class="block-label">Response headers</div>
|
<div class="block-label">Response headers</div>
|
||||||
|
|
@ -40,7 +41,12 @@ const endpointLabel = computed(() =>
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
size="small"
|
size="small"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
/>
|
>
|
||||||
|
<template #bodyCell="{ column, text }">
|
||||||
|
<span v-if="column.dataIndex === 'desc'" v-html="safeInlineHtml(text)"></span>
|
||||||
|
<template v-else>{{ text }}</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="!collapsed" class="endpoints">
|
<div v-show="!collapsed" class="endpoints">
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,28 @@
|
||||||
|
export function safeInlineHtml(input) {
|
||||||
|
if (!input) return '';
|
||||||
|
const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const open = '<code>';
|
||||||
|
const close = '</code>';
|
||||||
|
let out = '';
|
||||||
|
let i = 0;
|
||||||
|
while (i < input.length) {
|
||||||
|
const oIdx = input.indexOf(open, i);
|
||||||
|
if (oIdx === -1) {
|
||||||
|
out += escape(input.slice(i));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out += escape(input.slice(i, oIdx));
|
||||||
|
const cIdx = input.indexOf(close, oIdx + open.length);
|
||||||
|
if (cIdx === -1) {
|
||||||
|
out += escape(input.slice(oIdx));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out += '<code>' + escape(input.slice(oIdx + open.length, cIdx)) + '</code>';
|
||||||
|
i = cIdx + close.length;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export const sections = [
|
export const sections = [
|
||||||
{
|
{
|
||||||
id: 'auth',
|
id: 'auth',
|
||||||
|
|
@ -797,8 +822,13 @@ export const sections = [
|
||||||
id: 'websocket',
|
id: 'websocket',
|
||||||
title: 'WebSocket',
|
title: 'WebSocket',
|
||||||
description:
|
description:
|
||||||
'Real-time status updates via WebSocket. Connect once at <code>ws://<panel>/ws</code> to receive a stream of JSON messages without polling. Requires an authenticated session cookie (Bearer token auth is not supported). Each message has a <code>type</code> field that identifies the payload shape.',
|
'Real-time status updates via WebSocket. Connect once at <code>ws://<panel>/ws</code> to receive a stream of JSON messages without polling. Requires an authenticated session cookie (Bearer token auth is not supported). Each message has a <code>type</code> field that identifies the payload shape.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
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',
|
method: 'WS',
|
||||||
path: '→ type: status',
|
path: '→ type: status',
|
||||||
|
|
|
||||||
|
|
@ -13,146 +13,52 @@ type routeDef struct {
|
||||||
Path string
|
Path string
|
||||||
}
|
}
|
||||||
|
|
||||||
// expectedRoutes lists every route documented in frontend/src/pages/api-docs/endpoints.js.
|
|
||||||
// Keep this in sync when adding new endpoints to the docs.
|
|
||||||
var expectedRoutes = []routeDef{
|
|
||||||
// Authentication — no prefix
|
|
||||||
{Method: "POST", Path: "/login"},
|
|
||||||
{Method: "GET", Path: "/logout"},
|
|
||||||
{Method: "GET", Path: "/csrf-token"},
|
|
||||||
{Method: "POST", Path: "/getTwoFactorEnable"},
|
|
||||||
|
|
||||||
// Inbounds API — prefix /panel/api/inbounds
|
|
||||||
{Method: "GET", Path: "/panel/api/inbounds/list"},
|
|
||||||
{Method: "GET", Path: "/panel/api/inbounds/get/:id"},
|
|
||||||
{Method: "GET", Path: "/panel/api/inbounds/getClientTraffics/:email"},
|
|
||||||
{Method: "GET", Path: "/panel/api/inbounds/getClientTrafficsById/:id"},
|
|
||||||
{Method: "GET", Path: "/panel/api/inbounds/getSubLinks/:subId"},
|
|
||||||
{Method: "GET", Path: "/panel/api/inbounds/getClientLinks/:id/:email"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/add"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/del/:id"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/update/:id"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/setEnable/:id"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/clientIps/:email"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/clearClientIps/:email"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/addClient"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/:id/copyClients"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/:id/delClient/:clientId"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/updateClient/:clientId"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/:id/resetClientTraffic/:email"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/resetAllTraffics"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/resetAllClientTraffics/:id"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/delDepletedClients/:id"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/import"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/onlines"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/lastOnline"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/updateClientTraffic/:email"},
|
|
||||||
{Method: "POST", Path: "/panel/api/inbounds/:id/delClientByEmail/:email"},
|
|
||||||
|
|
||||||
// Server API — prefix /panel/api/server
|
|
||||||
{Method: "GET", Path: "/panel/api/server/status"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/cpuHistory/:bucket"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/history/:metric/:bucket"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/xrayMetricsState"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/xrayMetricsHistory/:metric/:bucket"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/xrayObservatory"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/xrayObservatoryHistory/:tag/:bucket"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/getXrayVersion"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/getPanelUpdateInfo"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/getConfigJson"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/getDb"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/getNewUUID"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/getNewX25519Cert"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/getNewmldsa65"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/getNewmlkem768"},
|
|
||||||
{Method: "GET", Path: "/panel/api/server/getNewVlessEnc"},
|
|
||||||
{Method: "POST", Path: "/panel/api/server/stopXrayService"},
|
|
||||||
{Method: "POST", Path: "/panel/api/server/restartXrayService"},
|
|
||||||
{Method: "POST", Path: "/panel/api/server/installXray/:version"},
|
|
||||||
{Method: "POST", Path: "/panel/api/server/updatePanel"},
|
|
||||||
{Method: "POST", Path: "/panel/api/server/updateGeofile"},
|
|
||||||
{Method: "POST", Path: "/panel/api/server/updateGeofile/:fileName"},
|
|
||||||
{Method: "POST", Path: "/panel/api/server/logs/:count"},
|
|
||||||
{Method: "POST", Path: "/panel/api/server/xraylogs/:count"},
|
|
||||||
{Method: "POST", Path: "/panel/api/server/importDB"},
|
|
||||||
{Method: "POST", Path: "/panel/api/server/getNewEchCert"},
|
|
||||||
|
|
||||||
// Nodes API — prefix /panel/api/nodes
|
|
||||||
{Method: "GET", Path: "/panel/api/nodes/list"},
|
|
||||||
{Method: "GET", Path: "/panel/api/nodes/get/:id"},
|
|
||||||
{Method: "POST", Path: "/panel/api/nodes/add"},
|
|
||||||
{Method: "POST", Path: "/panel/api/nodes/update/:id"},
|
|
||||||
{Method: "POST", Path: "/panel/api/nodes/del/:id"},
|
|
||||||
{Method: "POST", Path: "/panel/api/nodes/setEnable/:id"},
|
|
||||||
{Method: "POST", Path: "/panel/api/nodes/test"},
|
|
||||||
{Method: "POST", Path: "/panel/api/nodes/probe/:id"},
|
|
||||||
{Method: "GET", Path: "/panel/api/nodes/history/:id/:metric/:bucket"},
|
|
||||||
|
|
||||||
// Custom Geo API — prefix /panel/api/custom-geo
|
|
||||||
{Method: "GET", Path: "/panel/api/custom-geo/list"},
|
|
||||||
{Method: "GET", Path: "/panel/api/custom-geo/aliases"},
|
|
||||||
{Method: "POST", Path: "/panel/api/custom-geo/add"},
|
|
||||||
{Method: "POST", Path: "/panel/api/custom-geo/update/:id"},
|
|
||||||
{Method: "POST", Path: "/panel/api/custom-geo/delete/:id"},
|
|
||||||
{Method: "POST", Path: "/panel/api/custom-geo/download/:id"},
|
|
||||||
{Method: "POST", Path: "/panel/api/custom-geo/update-all"},
|
|
||||||
|
|
||||||
// Backup — prefix /panel/api
|
|
||||||
{Method: "GET", Path: "/panel/api/backuptotgbot"},
|
|
||||||
|
|
||||||
// Settings API — prefix /panel/setting
|
|
||||||
{Method: "POST", Path: "/panel/setting/all"},
|
|
||||||
{Method: "POST", Path: "/panel/setting/defaultSettings"},
|
|
||||||
{Method: "POST", Path: "/panel/setting/update"},
|
|
||||||
{Method: "POST", Path: "/panel/setting/updateUser"},
|
|
||||||
{Method: "POST", Path: "/panel/setting/restartPanel"},
|
|
||||||
{Method: "GET", Path: "/panel/setting/getDefaultJsonConfig"},
|
|
||||||
{Method: "GET", Path: "/panel/setting/getApiToken"},
|
|
||||||
{Method: "POST", Path: "/panel/setting/regenerateApiToken"},
|
|
||||||
|
|
||||||
// Xray Settings API — prefix /panel/xray
|
|
||||||
{Method: "POST", Path: "/panel/xray/"},
|
|
||||||
{Method: "GET", Path: "/panel/xray/getDefaultJsonConfig"},
|
|
||||||
{Method: "GET", Path: "/panel/xray/getOutboundsTraffic"},
|
|
||||||
{Method: "GET", Path: "/panel/xray/getXrayResult"},
|
|
||||||
{Method: "POST", Path: "/panel/xray/update"},
|
|
||||||
{Method: "POST", Path: "/panel/xray/warp/:action"},
|
|
||||||
{Method: "POST", Path: "/panel/xray/nord/:action"},
|
|
||||||
{Method: "POST", Path: "/panel/xray/resetOutboundsTraffic"},
|
|
||||||
{Method: "POST", Path: "/panel/xray/testOutbound"},
|
|
||||||
|
|
||||||
// WebSocket
|
|
||||||
{Method: "GET", Path: "/ws"},
|
|
||||||
|
|
||||||
// Subscription server — separate server (not on main Gin engine)
|
|
||||||
// Documented in Subscription Server section but not tested here
|
|
||||||
// because the sub server is a separate Gin engine.
|
|
||||||
// {Method: "GET", Path: "/sub/:subid"},
|
|
||||||
// {Method: "GET", Path: "/json/:subid"},
|
|
||||||
// {Method: "GET", Path: "/clash/:subid"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
|
// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
|
||||||
var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
|
var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
|
||||||
|
|
||||||
func TestAPIRoutesDocumented(t *testing.T) {
|
// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.js.
|
||||||
// Build a set of documented routes for fast lookup
|
var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
|
||||||
docSet := make(map[string]bool)
|
|
||||||
for _, r := range expectedRoutes {
|
// buildDocSet parses frontend/src/pages/api-docs/endpoints.js and returns the
|
||||||
key := r.Method + " " + r.Path
|
// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
|
||||||
if docSet[key] {
|
// placeholders (paths starting with /{...}) are skipped because they aren't
|
||||||
t.Errorf("Duplicate documented route: %s", key)
|
// registered on the main Gin engine.
|
||||||
|
func buildDocSet(t *testing.T) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
controllerDir, err := filepath.Abs(".")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get current dir: %v", err)
|
||||||
}
|
}
|
||||||
docSet[key] = true
|
endpointsPath := filepath.Join(controllerDir, "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.js")
|
||||||
|
data, err := os.ReadFile(endpointsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read endpoints.js at %s: %v", endpointsPath, err)
|
||||||
|
}
|
||||||
|
docSet := make(map[string]bool)
|
||||||
|
for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) {
|
||||||
|
method, path := m[1], m[2]
|
||||||
|
if method == "WS" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "/{") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
docSet[method+" "+path] = true
|
||||||
|
}
|
||||||
|
if len(docSet) == 0 {
|
||||||
|
t.Fatalf("no documented routes parsed from %s — regex or file format may have changed", endpointsPath)
|
||||||
|
}
|
||||||
|
return docSet
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk the web directory to find all Go files with route definitions
|
func TestAPIRoutesDocumented(t *testing.T) {
|
||||||
|
docSet := buildDocSet(t)
|
||||||
|
|
||||||
controllerDir, err := filepath.Abs(".")
|
controllerDir, err := filepath.Abs(".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get current dir: %v", err)
|
t.Fatalf("failed to get current dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all routes from the controller files
|
|
||||||
var allRoutes []routeDef
|
var allRoutes []routeDef
|
||||||
|
|
||||||
entries, err := os.ReadDir(controllerDir)
|
entries, err := os.ReadDir(controllerDir)
|
||||||
|
|
@ -212,7 +118,6 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
||||||
// The WebSocket route /ws is registered in web/web.go (not a controller file)
|
// The WebSocket route /ws is registered in web/web.go (not a controller file)
|
||||||
allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
|
allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
|
||||||
|
|
||||||
// Check each source route against the documented set
|
|
||||||
missingFromDocs := 0
|
missingFromDocs := 0
|
||||||
foundInDoc := 0
|
foundInDoc := 0
|
||||||
sourceSet := make(map[string]bool)
|
sourceSet := make(map[string]bool)
|
||||||
|
|
@ -228,7 +133,7 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
||||||
if spaPages[r.Path] {
|
if spaPages[r.Path] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Skip /panel/csrf-token (documented under auth)
|
// Skip /panel/csrf-token (documented under auth as /csrf-token)
|
||||||
if r.Path == "/panel/csrf-token" {
|
if r.Path == "/panel/csrf-token" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -246,18 +151,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Report undocumented documented routes
|
t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d",
|
||||||
extraInDocs := 0
|
len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
|
||||||
for _, r := range expectedRoutes {
|
|
||||||
key := r.Method + " " + r.Path
|
|
||||||
if !sourceSet[key] {
|
|
||||||
extraInDocs++
|
|
||||||
t.Logf("Documented route not found in source (perhaps deleted or moved): %s %s", r.Method, r.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d, extra in docs: %d",
|
|
||||||
len(sourceSet), len(docSet), foundInDoc, missingFromDocs, extraInDocs)
|
|
||||||
|
|
||||||
if missingFromDocs > 0 {
|
if missingFromDocs > 0 {
|
||||||
t.Errorf("Found %d undocumented route(s). Update endpoints.js to match.", missingFromDocs)
|
t.Errorf("Found %d undocumented route(s). Update endpoints.js to match.", missingFromDocs)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue