This commit is contained in:
MHSanaei 2026-05-13 01:46:03 +02:00
parent 34ea51f7b4
commit 26246ee810
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
5 changed files with 85 additions and 154 deletions

View file

@ -33,9 +33,9 @@ const tokenVisible = ref(false);
const searchQuery = ref('');
const collapsedSections = ref(new Set());
const curlExample = `curl -X GET \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json" \
const curlExample = `curl -X GET \\
-H "Authorization: Bearer YOUR_API_TOKEN" \\
-H "Accept: application/json" \\
https://your-panel.example.com/panel/api/inbounds/list`;
const sections = computed(() => {

View file

@ -1,6 +1,6 @@
<script setup>
import { computed } from 'vue';
import { methodColors } from './endpoints.js';
import { methodColors, safeInlineHtml } from './endpoints.js';
import CodeBlock from './CodeBlock.vue';
const props = defineProps({
@ -25,7 +25,7 @@ const paramColumns = [
<code class="endpoint-path">{{ endpoint.path }}</code>
</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 class="block-label">Parameters</div>
@ -127,7 +127,7 @@ body.dark .block-label {
}
body.dark .error-label {
color: rgba(255, 255, 255, 0.55);
color: #ff7b72;
}
body.dark .code-block {

View file

@ -5,6 +5,7 @@ import {
RightOutlined,
} from '@ant-design/icons-vue';
import EndpointRow from './EndpointRow.vue';
import { safeInlineHtml } from './endpoints.js';
const props = defineProps({
section: { type: Object, required: true },
@ -30,7 +31,7 @@ const endpointLabel = computed(() =>
</div>
<span class="endpoint-count">{{ endpointLabel }}</span>
</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 class="block-label">Response headers</div>
@ -40,7 +41,12 @@ const endpointLabel = computed(() =>
:pagination="false"
size="small"
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 v-show="!collapsed" class="endpoints">

View file

@ -1,3 +1,28 @@
export function safeInlineHtml(input) {
if (!input) return '';
const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 = [
{
id: 'auth',
@ -797,8 +822,13 @@ export const sections = [
id: 'websocket',
title: 'WebSocket',
description:
'Real-time status updates via WebSocket. Connect once at <code>ws://&lt;panel&gt;/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: [
{
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',
path: '→ type: status',

View file

@ -13,146 +13,52 @@ type routeDef struct {
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)
var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
func TestAPIRoutesDocumented(t *testing.T) {
// Build a set of documented routes for fast lookup
docSet := make(map[string]bool)
for _, r := range expectedRoutes {
key := r.Method + " " + r.Path
if docSet[key] {
t.Errorf("Duplicate documented route: %s", key)
}
docSet[key] = true
}
// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.js.
var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
// buildDocSet parses frontend/src/pages/api-docs/endpoints.js and returns the
// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
// placeholders (paths starting with /{...}) are skipped because they aren't
// 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)
}
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
}
func TestAPIRoutesDocumented(t *testing.T) {
docSet := buildDocSet(t)
// Walk the web directory to find all Go files with route definitions
controllerDir, err := filepath.Abs(".")
if err != nil {
t.Fatalf("failed to get current dir: %v", err)
}
// Collect all routes from the controller files
var allRoutes []routeDef
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)
allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
// Check each source route against the documented set
missingFromDocs := 0
foundInDoc := 0
sourceSet := make(map[string]bool)
@ -228,7 +133,7 @@ func TestAPIRoutesDocumented(t *testing.T) {
if spaPages[r.Path] {
continue
}
// Skip /panel/csrf-token (documented under auth)
// Skip /panel/csrf-token (documented under auth as /csrf-token)
if r.Path == "/panel/csrf-token" {
continue
}
@ -246,18 +151,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
}
}
// Report undocumented documented routes
extraInDocs := 0
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)
t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d",
len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
if missingFromDocs > 0 {
t.Errorf("Found %d undocumented route(s). Update endpoints.js to match.", missingFromDocs)