1
0

4 Коммиты 483952cfa0 ... c6f15cd53f

Автор SHA1 Сообщение Дата
  MHSanaei c6f15cd53f refactor(api)!: move /panel/setting and /panel/xray under /panel/api 1 день назад
  MHSanaei a014c01725 feat(api-docs): generate OpenAPI components/schemas from Go structs 1 день назад
  MHSanaei e56f6c63f6 fix(api-docs): target the panel base path in OpenAPI servers 1 день назад
  MHSanaei 83799d71b0 feat(api-docs): generate response examples from Go structs; fix SS2022 PSK regen (#4996) 1 день назад
43 измененных файлов с 4801 добавлено и 155 удалено
  1. 17 0
      .github/workflows/ci.yml
  2. 28 28
      database/model/model.go
  3. 1 0
      frontend/package.json
  4. 1785 1
      frontend/public/openapi.json
  5. 21 3
      frontend/scripts/build-openapi.mjs
  6. 2 2
      frontend/src/api/queries/useAllSettings.ts
  7. 396 0
      frontend/src/generated/examples.ts
  8. 1785 0
      frontend/src/generated/schemas.ts
  9. 37 0
      frontend/src/generated/types.ts
  10. 48 0
      frontend/src/generated/zod.ts
  11. 1 1
      frontend/src/hooks/useClients.ts
  12. 1 1
      frontend/src/hooks/useDatepicker.ts
  13. 7 7
      frontend/src/hooks/useXraySetting.ts
  14. 1 0
      frontend/src/pages/api-docs/ApiDocsPage.tsx
  15. 38 35
      frontend/src/pages/api-docs/endpoints.ts
  16. 12 1
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  17. 25 1
      frontend/src/pages/clients/ClientFormModal.tsx
  18. 1 1
      frontend/src/pages/inbounds/form/useSecurityActions.ts
  19. 1 1
      frontend/src/pages/inbounds/useInbounds.ts
  20. 1 1
      frontend/src/pages/index/BackupModal.tsx
  21. 1 1
      frontend/src/pages/index/IndexPage.tsx
  22. 5 5
      frontend/src/pages/settings/SecurityTab.tsx
  23. 1 1
      frontend/src/pages/settings/SettingsPage.tsx
  24. 6 6
      frontend/src/pages/xray/overrides/NordModal.tsx
  25. 5 5
      frontend/src/pages/xray/overrides/WarpModal.tsx
  26. 1 0
      frontend/src/schemas/client.ts
  27. 30 0
      frontend/src/test/generated-examples.test.ts
  28. 11 4
      frontend/src/utils/index.ts
  29. 1 1
      frontend/vite.config.js
  30. 171 0
      tools/openapigen/emit_examples.go
  31. 190 0
      tools/openapigen/emit_jsonschema.go
  32. 22 0
      tools/openapigen/main.go
  33. 3 1
      tools/openapigen/schema.go
  34. 3 1
      tools/openapigen/walker.go
  35. 15 7
      web/controller/api.go
  36. 2 2
      web/controller/api_docs_test.go
  37. 33 0
      web/controller/dist.go
  38. 42 0
      web/controller/dist_test.go
  39. 1 7
      web/controller/xui.go
  40. 5 5
      web/service/api_token.go
  41. 26 7
      web/service/inbound.go
  42. 7 7
      web/service/node.go
  43. 12 12
      xray/client_traffic.go

+ 17 - 0
.github/workflows/ci.yml

@@ -37,6 +37,23 @@ jobs:
           go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
           go test $(cat /tmp/go-packages.txt)
 
+  codegen:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v6
+      - uses: actions/setup-go@v6
+        with:
+          go-version-file: go.mod
+          cache: true
+      - uses: actions/setup-node@v6
+        with:
+          node-version-file: .nvmrc
+      - name: Regenerate schemas, examples and OpenAPI
+        run: npm run gen
+        working-directory: frontend
+      - name: Fail if generated files are stale (run 'npm run gen' and commit)
+        run: git diff --exit-code -- frontend/src/generated frontend/public/openapi.json
+
   govulncheck:
     runs-on: ubuntu-latest
     steps:

+ 28 - 28
database/model/model.go

@@ -41,13 +41,13 @@ type User struct {
 
 // Inbound represents an Xray inbound configuration with traffic statistics and settings.
 type Inbound struct {
-	Id                   int                  `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`                                                                                                                 // Unique identifier
+	Id                   int                  `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`                                                                                                     // Unique identifier
 	UserId               int                  `json:"-"`                                                                                                                                                            // Associated user ID
 	Up                   int64                `json:"up" form:"up"`                                                                                                                                                 // Upload traffic in bytes
 	Down                 int64                `json:"down" form:"down"`                                                                                                                                             // Download traffic in bytes
 	Total                int64                `json:"total" form:"total"`                                                                                                                                           // Total traffic limit in bytes
-	Remark               string               `json:"remark" form:"remark"`                                                                                                                                         // Human-readable remark
-	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"`                                                                                        // Whether the inbound is enabled
+	Remark               string               `json:"remark" form:"remark" example:"VLESS-443"`                                                                                                                     // Human-readable remark
+	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"`                                                                         // Whether the inbound is enabled
 	ExpiryTime           int64                `json:"expiryTime" form:"expiryTime"`                                                                                                                                 // Expiration timestamp
 	TrafficReset         string               `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
 	LastTrafficResetTime int64                `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"`                                                                                            // Last traffic reset timestamp
@@ -55,11 +55,11 @@ type Inbound struct {
 
 	// Xray configuration fields
 	Listen         string   `json:"listen" form:"listen"`
-	Port           int      `json:"port" form:"port" validate:"gte=0,lte=65535"`
-	Protocol       Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun"`
+	Port           int      `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
+	Protocol       Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun" example:"vless"`
 	Settings       string   `json:"settings" form:"settings"`
 	StreamSettings string   `json:"streamSettings" form:"streamSettings"`
-	Tag            string   `json:"tag" form:"tag" gorm:"unique"`
+	Tag            string   `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
 	Sniffing       string   `json:"sniffing" form:"sniffing"`
 	NodeID         *int     `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
 
@@ -378,15 +378,15 @@ type Setting struct {
 // endpoint over HTTP using the per-node ApiToken to populate the runtime
 // status fields below.
 type Node struct {
-	Id                  int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
-	Name                string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required"`
+	Id                  int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
+	Name                string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"`
 	Remark              string `json:"remark" form:"remark"`
-	Scheme              string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
-	Address             string `json:"address" form:"address" validate:"required"`
-	Port                int    `json:"port" form:"port" validate:"gte=1,lte=65535"`
-	BasePath            string `json:"basePath" form:"basePath"`
-	ApiToken            string `json:"apiToken" form:"apiToken" validate:"required"`
-	Enable              bool   `json:"enable" form:"enable" gorm:"default:true"`
+	Scheme              string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"`
+	Address             string `json:"address" form:"address" validate:"required" example:"node1.example.com"`
+	Port                int    `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
+	BasePath            string `json:"basePath" form:"basePath" example:"/"`
+	ApiToken            string `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
+	Enable              bool   `json:"enable" form:"enable" gorm:"default:true" example:"true"`
 	AllowPrivateAddress bool   `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
 	TlsVerifyMode       string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
 	PinnedCertSha256    string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
@@ -401,23 +401,23 @@ type Node struct {
 	// Heartbeat-updated fields. UpdatedAt advances on every probe even when
 	// the row is otherwise unchanged so the UI's "last seen" tooltip is
 	// truthful without us having to read LastHeartbeat separately.
-	Status        string  `json:"status" gorm:"default:unknown"` // online|offline|unknown
-	LastHeartbeat int64   `json:"lastHeartbeat"`                 // unix seconds, 0 = never
-	LatencyMs     int     `json:"latencyMs"`
-	XrayVersion   string  `json:"xrayVersion"`
-	PanelVersion  string  `json:"panelVersion" gorm:"column:panel_version"`
-	CpuPct        float64 `json:"cpuPct"`
-	MemPct        float64 `json:"memPct"`
-	UptimeSecs    uint64  `json:"uptimeSecs"`
+	Status        string  `json:"status" gorm:"default:unknown" example:"online"` // online|offline|unknown
+	LastHeartbeat int64   `json:"lastHeartbeat" example:"1700000000"`             // unix seconds, 0 = never
+	LatencyMs     int     `json:"latencyMs" example:"42"`
+	XrayVersion   string  `json:"xrayVersion" example:"25.10.31"`
+	PanelVersion  string  `json:"panelVersion" gorm:"column:panel_version" example:"v3.x.x"`
+	CpuPct        float64 `json:"cpuPct" example:"23.5"`
+	MemPct        float64 `json:"memPct" example:"45.1"`
+	UptimeSecs    uint64  `json:"uptimeSecs" example:"86400"`
 	LastError     string  `json:"lastError"`
 
 	ConfigDirty   bool  `json:"configDirty" gorm:"default:false"`
 	ConfigDirtyAt int64 `json:"configDirtyAt"`
 
-	InboundCount  int `json:"inboundCount" gorm:"-"`
-	ClientCount   int `json:"clientCount" gorm:"-"`
-	OnlineCount   int `json:"onlineCount" gorm:"-"`
-	DepletedCount int `json:"depletedCount" gorm:"-"`
+	InboundCount  int `json:"inboundCount" gorm:"-" example:"5"`
+	ClientCount   int `json:"clientCount" gorm:"-" example:"27"`
+	OnlineCount   int `json:"onlineCount" gorm:"-" example:"3"`
+	DepletedCount int `json:"depletedCount" gorm:"-" example:"1"`
 
 	// ParentGuid + Transitive are set only when a node is surfaced as part of a
 	// node tree (#4983): direct nodes carry the master panel's own GUID, a
@@ -426,8 +426,8 @@ type Node struct {
 	ParentGuid string `json:"parentGuid,omitempty" gorm:"-"`
 	Transitive bool   `json:"transitive,omitempty" gorm:"-"`
 
-	CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
-	UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
+	CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli" example:"1700000000"`
+	UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli" example:"1700000000"`
 }
 
 // NodeSummary is the read-only identity of a node as published one hop up: the

+ 1 - 0
frontend/package.json

@@ -16,6 +16,7 @@
     "typecheck": "tsc --noEmit",
     "test": "vitest run",
     "test:watch": "vitest",
+    "gen": "npm run gen:zod && npm run gen:api",
     "gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
     "gen:zod": "cd .. && go run ./tools/openapigen"
   },

Разница между файлами не показана из-за своего большого размера
+ 1785 - 1
frontend/public/openapi.json


+ 21 - 3
frontend/scripts/build-openapi.mjs

@@ -4,6 +4,8 @@ import { join, dirname } from 'node:path';
 import { fileURLToPath, pathToFileURL } from 'node:url';
 
 import { sections } from '../src/pages/api-docs/endpoints.ts';
+import { EXAMPLES } from '../src/generated/examples.ts';
+import { SCHEMAS } from '../src/generated/schemas.ts';
 
 const __dirname = dirname(fileURLToPath(import.meta.url));
 const outPath = join(__dirname, '..', 'public', 'openapi.json');
@@ -128,7 +130,22 @@ function buildOperation(ep, tag) {
   }
 
   const responses = {};
-  const successExample = tryParseJson(ep.response);
+  let successExample = tryParseJson(ep.response);
+  let objSchema = {};
+  if (ep.responseSchema) {
+    const obj = EXAMPLES[ep.responseSchema];
+    if (obj === undefined) {
+      throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`);
+    }
+    if (SCHEMAS[ep.responseSchema] === undefined) {
+      throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`);
+    }
+    const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
+    objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
+    if (successExample === undefined) {
+      successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
+    }
+  }
   responses['200'] = {
     description: 'Successful response',
     content: {
@@ -138,7 +155,7 @@ function buildOperation(ep, tag) {
           properties: {
             success: { type: 'boolean' },
             msg: { type: 'string' },
-            obj: {},
+            obj: objSchema,
           },
         },
         ...(successExample !== undefined ? { example: successExample } : {}),
@@ -192,13 +209,14 @@ function buildSpec() {
       title: '3X-UI Panel API',
       version: PANEL_VERSION,
       description:
-        'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes.',
+        'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
     },
     servers: [
       { url: '/', description: 'Current panel (basePath aware)' },
     ],
     components: {
       securitySchemes: SECURITY_SCHEMES,
+      schemas: SCHEMAS,
     },
     security: [{ bearerAuth: [] }, { cookieAuth: [] }],
     tags,

+ 2 - 2
frontend/src/api/queries/useAllSettings.ts

@@ -8,7 +8,7 @@ import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
 import { keys } from '@/api/queryKeys';
 
 async function fetchAllSetting(): Promise<AllSettingInput | null> {
-  const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
+  const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
   const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
   return validated.obj;
@@ -47,7 +47,7 @@ export function useAllSettings() {
       if (!body.success) {
         console.warn('[zod] setting/update body failed validation', body.error.issues);
       }
-      return HttpUtil.post('/panel/setting/update', body.success ? body.data : next);
+      return HttpUtil.post('/panel/api/setting/update', body.success ? body.data : next);
     },
     onSuccess: (msg) => {
       if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });

+ 396 - 0
frontend/src/generated/examples.ts

@@ -0,0 +1,396 @@
+// Code generated by tools/openapigen. DO NOT EDIT.
+export const EXAMPLES: Record<string, unknown> = {
+  "AllSetting": {
+    "datepicker": "",
+    "expireDiff": 0,
+    "externalTrafficInformEnable": false,
+    "externalTrafficInformURI": "",
+    "ldapAutoCreate": false,
+    "ldapAutoDelete": false,
+    "ldapBaseDN": "",
+    "ldapBindDN": "",
+    "ldapDefaultExpiryDays": 0,
+    "ldapDefaultLimitIP": 0,
+    "ldapDefaultTotalGB": 0,
+    "ldapEnable": false,
+    "ldapFlagField": "",
+    "ldapHost": "",
+    "ldapInboundTags": "",
+    "ldapInvertFlag": false,
+    "ldapPassword": "",
+    "ldapPort": 0,
+    "ldapSyncCron": "",
+    "ldapTruthyValues": "",
+    "ldapUseTLS": false,
+    "ldapUserAttr": "",
+    "ldapUserFilter": "",
+    "ldapVlessField": "",
+    "pageSize": 0,
+    "panelProxy": "",
+    "remarkModel": "",
+    "restartXrayOnClientDisable": false,
+    "sessionMaxAge": 1,
+    "subAnnounce": "",
+    "subCertFile": "",
+    "subClashEnable": false,
+    "subClashEnableRouting": false,
+    "subClashPath": "",
+    "subClashRules": "",
+    "subClashURI": "",
+    "subDomain": "",
+    "subEmailInRemark": false,
+    "subEnable": false,
+    "subEnableRouting": false,
+    "subEncrypt": false,
+    "subJsonEnable": false,
+    "subJsonFinalMask": "",
+    "subJsonMux": "",
+    "subJsonPath": "",
+    "subJsonRules": "",
+    "subJsonURI": "",
+    "subKeyFile": "",
+    "subListen": "",
+    "subPath": "",
+    "subPort": 1,
+    "subProfileUrl": "",
+    "subRoutingRules": "",
+    "subShowInfo": false,
+    "subSupportUrl": "",
+    "subTitle": "",
+    "subURI": "",
+    "subUpdates": 0,
+    "tgBotAPIServer": "",
+    "tgBotBackup": false,
+    "tgBotChatId": "",
+    "tgBotEnable": false,
+    "tgBotLoginNotify": false,
+    "tgBotProxy": "",
+    "tgBotToken": "",
+    "tgCpu": 0,
+    "tgLang": "",
+    "tgRunTime": "",
+    "timeLocation": "",
+    "trafficDiff": 0,
+    "trustedProxyCIDRs": "",
+    "twoFactorEnable": false,
+    "twoFactorToken": "",
+    "webBasePath": "",
+    "webCertFile": "",
+    "webDomain": "",
+    "webKeyFile": "",
+    "webListen": "",
+    "webPort": 1
+  },
+  "AllSettingView": {
+    "datepicker": "",
+    "expireDiff": 0,
+    "externalTrafficInformEnable": false,
+    "externalTrafficInformURI": "",
+    "hasApiToken": false,
+    "hasLdapPassword": false,
+    "hasNordSecret": false,
+    "hasTgBotToken": false,
+    "hasTwoFactorToken": false,
+    "hasWarpSecret": false,
+    "ldapAutoCreate": false,
+    "ldapAutoDelete": false,
+    "ldapBaseDN": "",
+    "ldapBindDN": "",
+    "ldapDefaultExpiryDays": 0,
+    "ldapDefaultLimitIP": 0,
+    "ldapDefaultTotalGB": 0,
+    "ldapEnable": false,
+    "ldapFlagField": "",
+    "ldapHost": "",
+    "ldapInboundTags": "",
+    "ldapInvertFlag": false,
+    "ldapPassword": "",
+    "ldapPort": 0,
+    "ldapSyncCron": "",
+    "ldapTruthyValues": "",
+    "ldapUseTLS": false,
+    "ldapUserAttr": "",
+    "ldapUserFilter": "",
+    "ldapVlessField": "",
+    "pageSize": 0,
+    "panelProxy": "",
+    "remarkModel": "",
+    "restartXrayOnClientDisable": false,
+    "sessionMaxAge": 1,
+    "subAnnounce": "",
+    "subCertFile": "",
+    "subClashEnable": false,
+    "subClashEnableRouting": false,
+    "subClashPath": "",
+    "subClashRules": "",
+    "subClashURI": "",
+    "subDomain": "",
+    "subEmailInRemark": false,
+    "subEnable": false,
+    "subEnableRouting": false,
+    "subEncrypt": false,
+    "subJsonEnable": false,
+    "subJsonFinalMask": "",
+    "subJsonMux": "",
+    "subJsonPath": "",
+    "subJsonRules": "",
+    "subJsonURI": "",
+    "subKeyFile": "",
+    "subListen": "",
+    "subPath": "",
+    "subPort": 1,
+    "subProfileUrl": "",
+    "subRoutingRules": "",
+    "subShowInfo": false,
+    "subSupportUrl": "",
+    "subTitle": "",
+    "subURI": "",
+    "subUpdates": 0,
+    "tgBotAPIServer": "",
+    "tgBotBackup": false,
+    "tgBotChatId": "",
+    "tgBotEnable": false,
+    "tgBotLoginNotify": false,
+    "tgBotProxy": "",
+    "tgBotToken": "",
+    "tgCpu": 0,
+    "tgLang": "",
+    "tgRunTime": "",
+    "timeLocation": "",
+    "trafficDiff": 0,
+    "trustedProxyCIDRs": "",
+    "twoFactorEnable": false,
+    "twoFactorToken": "",
+    "webBasePath": "",
+    "webCertFile": "",
+    "webDomain": "",
+    "webKeyFile": "",
+    "webListen": "",
+    "webPort": 1
+  },
+  "ApiToken": {
+    "createdAt": 0,
+    "enabled": false,
+    "id": 0,
+    "name": "",
+    "token": ""
+  },
+  "ApiTokenView": {
+    "createdAt": 1736000000,
+    "enabled": true,
+    "id": 2,
+    "name": "central-panel-a",
+    "token": "new-token-string"
+  },
+  "Client": {
+    "auth": "",
+    "comment": "",
+    "created_at": 0,
+    "email": "",
+    "enable": false,
+    "expiryTime": 0,
+    "flow": "",
+    "group": "",
+    "id": "",
+    "limitIp": 0,
+    "password": "",
+    "reset": 0,
+    "reverse": null,
+    "security": "",
+    "subId": "",
+    "tgId": 0,
+    "totalGB": 0,
+    "updated_at": 0
+  },
+  "ClientInbound": {
+    "clientId": 0,
+    "createdAt": 0,
+    "flowOverride": "",
+    "inboundId": 0
+  },
+  "ClientRecord": {
+    "auth": "",
+    "comment": "",
+    "createdAt": 0,
+    "email": "",
+    "enable": false,
+    "expiryTime": 0,
+    "flow": "",
+    "group": "",
+    "id": 0,
+    "limitIp": 0,
+    "password": "",
+    "reset": 0,
+    "reverse": null,
+    "security": "",
+    "subId": "",
+    "tgId": 0,
+    "totalGB": 0,
+    "updatedAt": 0,
+    "uuid": ""
+  },
+  "ClientReverse": {
+    "tag": ""
+  },
+  "ClientTraffic": {
+    "down": 2097152,
+    "email": "user1",
+    "enable": true,
+    "expiryTime": 1735689600000,
+    "id": 14825,
+    "inboundId": 1,
+    "lastOnline": 1735680000000,
+    "reset": 0,
+    "subId": "i7tvdpeffi0hvvf1",
+    "total": 10737418240,
+    "up": 1048576,
+    "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
+  },
+  "CustomGeoResource": {
+    "alias": "",
+    "createdAt": 0,
+    "id": 0,
+    "lastModified": "",
+    "lastUpdatedAt": 0,
+    "localPath": "",
+    "type": "",
+    "updatedAt": 0,
+    "url": ""
+  },
+  "FallbackParentInfo": {
+    "masterId": 0,
+    "path": ""
+  },
+  "HistoryOfSeeders": {
+    "id": 0,
+    "seederName": ""
+  },
+  "Inbound": {
+    "clientStats": [
+      {
+        "down": 2097152,
+        "email": "user1",
+        "enable": true,
+        "expiryTime": 1735689600000,
+        "id": 14825,
+        "inboundId": 1,
+        "lastOnline": 1735680000000,
+        "reset": 0,
+        "subId": "i7tvdpeffi0hvvf1",
+        "total": 10737418240,
+        "up": 1048576,
+        "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
+      }
+    ],
+    "down": 0,
+    "enable": true,
+    "expiryTime": 0,
+    "fallbackParent": null,
+    "id": 1,
+    "lastTrafficResetTime": 0,
+    "listen": "",
+    "nodeId": null,
+    "originNodeGuid": "",
+    "port": 443,
+    "protocol": "vless",
+    "remark": "VLESS-443",
+    "settings": null,
+    "sniffing": null,
+    "streamSettings": null,
+    "tag": "in-443-tcp",
+    "total": 0,
+    "trafficReset": "never",
+    "up": 0
+  },
+  "InboundClientIps": {
+    "clientEmail": "",
+    "id": 0,
+    "ips": null
+  },
+  "InboundFallback": {
+    "alpn": "",
+    "childId": 0,
+    "dest": "",
+    "id": 0,
+    "masterId": 0,
+    "name": "",
+    "path": "",
+    "sortOrder": 0,
+    "xver": 0
+  },
+  "InboundOption": {
+    "id": 1,
+    "port": 443,
+    "protocol": "vless",
+    "remark": "VLESS-443",
+    "ssMethod": "",
+    "tag": "in-443-tcp",
+    "tlsFlowCapable": true
+  },
+  "Msg": {
+    "msg": "",
+    "obj": null,
+    "success": false
+  },
+  "Node": {
+    "address": "node1.example.com",
+    "allowPrivateAddress": false,
+    "apiToken": "abcdef0123456789",
+    "basePath": "/",
+    "clientCount": 27,
+    "configDirty": false,
+    "configDirtyAt": 0,
+    "cpuPct": 23.5,
+    "createdAt": 1700000000,
+    "depletedCount": 1,
+    "enable": true,
+    "guid": "",
+    "id": 1,
+    "inboundCount": 5,
+    "lastError": "",
+    "lastHeartbeat": 1700000000,
+    "latencyMs": 42,
+    "memPct": 45.1,
+    "name": "de-fra-1",
+    "onlineCount": 3,
+    "panelVersion": "v3.x.x",
+    "parentGuid": "",
+    "pinnedCertSha256": "",
+    "port": 2053,
+    "remark": "",
+    "scheme": "https",
+    "status": "online",
+    "tlsVerifyMode": "verify",
+    "transitive": false,
+    "updatedAt": 1700000000,
+    "uptimeSecs": 86400,
+    "xrayVersion": "25.10.31"
+  },
+  "OutboundTraffics": {
+    "down": 0,
+    "id": 0,
+    "tag": "",
+    "total": 0,
+    "up": 0
+  },
+  "ProbeResultUI": {
+    "cpuPct": 12.5,
+    "error": "",
+    "latencyMs": 42,
+    "memPct": 45.2,
+    "panelVersion": "v3.x.x",
+    "status": "online",
+    "uptimeSecs": 86400,
+    "xrayVersion": "25.10.31"
+  },
+  "Setting": {
+    "id": 0,
+    "key": "",
+    "value": ""
+  },
+  "User": {
+    "id": 0,
+    "password": "",
+    "username": ""
+  }
+};

+ 1785 - 0
frontend/src/generated/schemas.ts

@@ -0,0 +1,1785 @@
+// Code generated by tools/openapigen. DO NOT EDIT.
+export const SCHEMAS: Record<string, unknown> = {
+  "AllSetting": {
+    "description": "AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.",
+    "properties": {
+      "datepicker": {
+        "description": "Date picker format",
+        "type": "string"
+      },
+      "expireDiff": {
+        "description": "Expiration warning threshold in days",
+        "minimum": 0,
+        "type": "integer"
+      },
+      "externalTrafficInformEnable": {
+        "description": "Enable external traffic reporting",
+        "type": "boolean"
+      },
+      "externalTrafficInformURI": {
+        "description": "URI for external traffic reporting",
+        "type": "string"
+      },
+      "ldapAutoCreate": {
+        "type": "boolean"
+      },
+      "ldapAutoDelete": {
+        "type": "boolean"
+      },
+      "ldapBaseDN": {
+        "type": "string"
+      },
+      "ldapBindDN": {
+        "type": "string"
+      },
+      "ldapDefaultExpiryDays": {
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapDefaultLimitIP": {
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapDefaultTotalGB": {
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapEnable": {
+        "description": "LDAP settings",
+        "type": "boolean"
+      },
+      "ldapFlagField": {
+        "description": "Generic flag configuration",
+        "type": "string"
+      },
+      "ldapHost": {
+        "type": "string"
+      },
+      "ldapInboundTags": {
+        "type": "string"
+      },
+      "ldapInvertFlag": {
+        "type": "boolean"
+      },
+      "ldapPassword": {
+        "type": "string"
+      },
+      "ldapPort": {
+        "maximum": 65535,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapSyncCron": {
+        "type": "string"
+      },
+      "ldapTruthyValues": {
+        "type": "string"
+      },
+      "ldapUseTLS": {
+        "type": "boolean"
+      },
+      "ldapUserAttr": {
+        "description": "e.g., mail or uid",
+        "type": "string"
+      },
+      "ldapUserFilter": {
+        "type": "string"
+      },
+      "ldapVlessField": {
+        "type": "string"
+      },
+      "pageSize": {
+        "description": "UI settings\nNumber of items per page in lists (0 disables pagination)",
+        "maximum": 1000,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "panelProxy": {
+        "description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)",
+        "type": "string"
+      },
+      "remarkModel": {
+        "description": "Remark model pattern for inbounds",
+        "type": "string"
+      },
+      "restartXrayOnClientDisable": {
+        "description": "Restart Xray when clients are auto-disabled by expiry/traffic limit",
+        "type": "boolean"
+      },
+      "sessionMaxAge": {
+        "description": "Session maximum age in minutes (cap at one year)",
+        "maximum": 525600,
+        "minimum": 1,
+        "type": "integer"
+      },
+      "subAnnounce": {
+        "description": "Subscription announce",
+        "type": "string"
+      },
+      "subCertFile": {
+        "description": "SSL certificate file for subscription server",
+        "type": "string"
+      },
+      "subClashEnable": {
+        "description": "Enable Clash/Mihomo subscription endpoint",
+        "type": "boolean"
+      },
+      "subClashEnableRouting": {
+        "description": "Enable global routing rules for Clash/Mihomo",
+        "type": "boolean"
+      },
+      "subClashPath": {
+        "description": "Path for Clash/Mihomo subscription endpoint",
+        "type": "string"
+      },
+      "subClashRules": {
+        "description": "Clash/Mihomo global routing rules",
+        "type": "string"
+      },
+      "subClashURI": {
+        "description": "Clash/Mihomo subscription server URI",
+        "type": "string"
+      },
+      "subDomain": {
+        "description": "Domain for subscription server validation",
+        "type": "string"
+      },
+      "subEmailInRemark": {
+        "description": "Include email in subscription remark/name",
+        "type": "boolean"
+      },
+      "subEnable": {
+        "description": "Subscription server settings\nEnable subscription server",
+        "type": "boolean"
+      },
+      "subEnableRouting": {
+        "description": "Enable routing for subscription",
+        "type": "boolean"
+      },
+      "subEncrypt": {
+        "description": "Encrypt subscription responses",
+        "type": "boolean"
+      },
+      "subJsonEnable": {
+        "description": "Enable JSON subscription endpoint",
+        "type": "boolean"
+      },
+      "subJsonFinalMask": {
+        "description": "JSON subscription global finalmask (tcp/udp masks + quicParams)",
+        "type": "string"
+      },
+      "subJsonMux": {
+        "description": "JSON subscription mux configuration",
+        "type": "string"
+      },
+      "subJsonPath": {
+        "description": "Path for JSON subscription endpoint",
+        "type": "string"
+      },
+      "subJsonRules": {
+        "type": "string"
+      },
+      "subJsonURI": {
+        "description": "JSON subscription server URI",
+        "type": "string"
+      },
+      "subKeyFile": {
+        "description": "SSL private key file for subscription server",
+        "type": "string"
+      },
+      "subListen": {
+        "description": "Subscription server listen IP",
+        "type": "string"
+      },
+      "subPath": {
+        "description": "Base path for subscription URLs",
+        "type": "string"
+      },
+      "subPort": {
+        "description": "Subscription server port",
+        "maximum": 65535,
+        "minimum": 1,
+        "type": "integer"
+      },
+      "subProfileUrl": {
+        "description": "Subscription profile URL",
+        "type": "string"
+      },
+      "subRoutingRules": {
+        "description": "Subscription global routing rules (Only for Happ)",
+        "type": "string"
+      },
+      "subShowInfo": {
+        "description": "Show client information in subscriptions",
+        "type": "boolean"
+      },
+      "subSupportUrl": {
+        "description": "Subscription support URL",
+        "type": "string"
+      },
+      "subTitle": {
+        "description": "Subscription title",
+        "type": "string"
+      },
+      "subURI": {
+        "description": "Subscription server URI",
+        "type": "string"
+      },
+      "subUpdates": {
+        "description": "Subscription update interval in minutes",
+        "maximum": 525600,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "tgBotAPIServer": {
+        "description": "Custom API server for Telegram bot",
+        "type": "string"
+      },
+      "tgBotBackup": {
+        "description": "Enable database backup via Telegram",
+        "type": "boolean"
+      },
+      "tgBotChatId": {
+        "description": "Telegram chat ID for notifications",
+        "type": "string"
+      },
+      "tgBotEnable": {
+        "description": "Telegram bot settings\nEnable Telegram bot notifications",
+        "type": "boolean"
+      },
+      "tgBotLoginNotify": {
+        "description": "Send login notifications",
+        "type": "boolean"
+      },
+      "tgBotProxy": {
+        "description": "Proxy URL for Telegram bot",
+        "type": "string"
+      },
+      "tgBotToken": {
+        "description": "Telegram bot token",
+        "type": "string"
+      },
+      "tgCpu": {
+        "description": "CPU usage threshold for alerts (percent)",
+        "maximum": 100,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "tgLang": {
+        "description": "Telegram bot language",
+        "type": "string"
+      },
+      "tgRunTime": {
+        "description": "Cron schedule for Telegram notifications",
+        "type": "string"
+      },
+      "timeLocation": {
+        "description": "Security settings\nTime zone location",
+        "type": "string"
+      },
+      "trafficDiff": {
+        "description": "Traffic warning threshold percentage",
+        "maximum": 100,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "trustedProxyCIDRs": {
+        "description": "Trusted reverse proxy IPs/CIDRs for forwarded headers",
+        "type": "string"
+      },
+      "twoFactorEnable": {
+        "description": "Enable two-factor authentication",
+        "type": "boolean"
+      },
+      "twoFactorToken": {
+        "description": "Two-factor authentication token",
+        "type": "string"
+      },
+      "webBasePath": {
+        "description": "Base path for web panel URLs",
+        "type": "string"
+      },
+      "webCertFile": {
+        "description": "Path to SSL certificate file for web server",
+        "type": "string"
+      },
+      "webDomain": {
+        "description": "Web server domain for domain validation",
+        "type": "string"
+      },
+      "webKeyFile": {
+        "description": "Path to SSL private key file for web server",
+        "type": "string"
+      },
+      "webListen": {
+        "description": "Web server settings\nWeb server listen IP address",
+        "type": "string"
+      },
+      "webPort": {
+        "description": "Web server port number",
+        "maximum": 65535,
+        "minimum": 1,
+        "type": "integer"
+      }
+    },
+    "required": [
+      "datepicker",
+      "expireDiff",
+      "externalTrafficInformEnable",
+      "externalTrafficInformURI",
+      "ldapAutoCreate",
+      "ldapAutoDelete",
+      "ldapBaseDN",
+      "ldapBindDN",
+      "ldapDefaultExpiryDays",
+      "ldapDefaultLimitIP",
+      "ldapDefaultTotalGB",
+      "ldapEnable",
+      "ldapFlagField",
+      "ldapHost",
+      "ldapInboundTags",
+      "ldapInvertFlag",
+      "ldapPassword",
+      "ldapPort",
+      "ldapSyncCron",
+      "ldapTruthyValues",
+      "ldapUseTLS",
+      "ldapUserAttr",
+      "ldapUserFilter",
+      "ldapVlessField",
+      "pageSize",
+      "panelProxy",
+      "remarkModel",
+      "restartXrayOnClientDisable",
+      "sessionMaxAge",
+      "subAnnounce",
+      "subCertFile",
+      "subClashEnable",
+      "subClashEnableRouting",
+      "subClashPath",
+      "subClashRules",
+      "subClashURI",
+      "subDomain",
+      "subEmailInRemark",
+      "subEnable",
+      "subEnableRouting",
+      "subEncrypt",
+      "subJsonEnable",
+      "subJsonFinalMask",
+      "subJsonMux",
+      "subJsonPath",
+      "subJsonRules",
+      "subJsonURI",
+      "subKeyFile",
+      "subListen",
+      "subPath",
+      "subPort",
+      "subProfileUrl",
+      "subRoutingRules",
+      "subShowInfo",
+      "subSupportUrl",
+      "subTitle",
+      "subURI",
+      "subUpdates",
+      "tgBotAPIServer",
+      "tgBotBackup",
+      "tgBotChatId",
+      "tgBotEnable",
+      "tgBotLoginNotify",
+      "tgBotProxy",
+      "tgBotToken",
+      "tgCpu",
+      "tgLang",
+      "tgRunTime",
+      "timeLocation",
+      "trafficDiff",
+      "trustedProxyCIDRs",
+      "twoFactorEnable",
+      "twoFactorToken",
+      "webBasePath",
+      "webCertFile",
+      "webDomain",
+      "webKeyFile",
+      "webListen",
+      "webPort"
+    ],
+    "type": "object"
+  },
+  "AllSettingView": {
+    "description": "AllSettingView is the browser-safe settings read model. Secret values\nare redacted from the embedded write model and represented by presence\nflags so the UI can show configured/not configured state.",
+    "properties": {
+      "datepicker": {
+        "description": "Date picker format",
+        "type": "string"
+      },
+      "expireDiff": {
+        "description": "Expiration warning threshold in days",
+        "minimum": 0,
+        "type": "integer"
+      },
+      "externalTrafficInformEnable": {
+        "description": "Enable external traffic reporting",
+        "type": "boolean"
+      },
+      "externalTrafficInformURI": {
+        "description": "URI for external traffic reporting",
+        "type": "string"
+      },
+      "hasApiToken": {
+        "type": "boolean"
+      },
+      "hasLdapPassword": {
+        "type": "boolean"
+      },
+      "hasNordSecret": {
+        "type": "boolean"
+      },
+      "hasTgBotToken": {
+        "type": "boolean"
+      },
+      "hasTwoFactorToken": {
+        "type": "boolean"
+      },
+      "hasWarpSecret": {
+        "type": "boolean"
+      },
+      "ldapAutoCreate": {
+        "type": "boolean"
+      },
+      "ldapAutoDelete": {
+        "type": "boolean"
+      },
+      "ldapBaseDN": {
+        "type": "string"
+      },
+      "ldapBindDN": {
+        "type": "string"
+      },
+      "ldapDefaultExpiryDays": {
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapDefaultLimitIP": {
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapDefaultTotalGB": {
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapEnable": {
+        "description": "LDAP settings",
+        "type": "boolean"
+      },
+      "ldapFlagField": {
+        "description": "Generic flag configuration",
+        "type": "string"
+      },
+      "ldapHost": {
+        "type": "string"
+      },
+      "ldapInboundTags": {
+        "type": "string"
+      },
+      "ldapInvertFlag": {
+        "type": "boolean"
+      },
+      "ldapPassword": {
+        "type": "string"
+      },
+      "ldapPort": {
+        "maximum": 65535,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapSyncCron": {
+        "type": "string"
+      },
+      "ldapTruthyValues": {
+        "type": "string"
+      },
+      "ldapUseTLS": {
+        "type": "boolean"
+      },
+      "ldapUserAttr": {
+        "description": "e.g., mail or uid",
+        "type": "string"
+      },
+      "ldapUserFilter": {
+        "type": "string"
+      },
+      "ldapVlessField": {
+        "type": "string"
+      },
+      "pageSize": {
+        "description": "UI settings\nNumber of items per page in lists (0 disables pagination)",
+        "maximum": 1000,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "panelProxy": {
+        "description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)",
+        "type": "string"
+      },
+      "remarkModel": {
+        "description": "Remark model pattern for inbounds",
+        "type": "string"
+      },
+      "restartXrayOnClientDisable": {
+        "description": "Restart Xray when clients are auto-disabled by expiry/traffic limit",
+        "type": "boolean"
+      },
+      "sessionMaxAge": {
+        "description": "Session maximum age in minutes (cap at one year)",
+        "maximum": 525600,
+        "minimum": 1,
+        "type": "integer"
+      },
+      "subAnnounce": {
+        "description": "Subscription announce",
+        "type": "string"
+      },
+      "subCertFile": {
+        "description": "SSL certificate file for subscription server",
+        "type": "string"
+      },
+      "subClashEnable": {
+        "description": "Enable Clash/Mihomo subscription endpoint",
+        "type": "boolean"
+      },
+      "subClashEnableRouting": {
+        "description": "Enable global routing rules for Clash/Mihomo",
+        "type": "boolean"
+      },
+      "subClashPath": {
+        "description": "Path for Clash/Mihomo subscription endpoint",
+        "type": "string"
+      },
+      "subClashRules": {
+        "description": "Clash/Mihomo global routing rules",
+        "type": "string"
+      },
+      "subClashURI": {
+        "description": "Clash/Mihomo subscription server URI",
+        "type": "string"
+      },
+      "subDomain": {
+        "description": "Domain for subscription server validation",
+        "type": "string"
+      },
+      "subEmailInRemark": {
+        "description": "Include email in subscription remark/name",
+        "type": "boolean"
+      },
+      "subEnable": {
+        "description": "Subscription server settings\nEnable subscription server",
+        "type": "boolean"
+      },
+      "subEnableRouting": {
+        "description": "Enable routing for subscription",
+        "type": "boolean"
+      },
+      "subEncrypt": {
+        "description": "Encrypt subscription responses",
+        "type": "boolean"
+      },
+      "subJsonEnable": {
+        "description": "Enable JSON subscription endpoint",
+        "type": "boolean"
+      },
+      "subJsonFinalMask": {
+        "description": "JSON subscription global finalmask (tcp/udp masks + quicParams)",
+        "type": "string"
+      },
+      "subJsonMux": {
+        "description": "JSON subscription mux configuration",
+        "type": "string"
+      },
+      "subJsonPath": {
+        "description": "Path for JSON subscription endpoint",
+        "type": "string"
+      },
+      "subJsonRules": {
+        "type": "string"
+      },
+      "subJsonURI": {
+        "description": "JSON subscription server URI",
+        "type": "string"
+      },
+      "subKeyFile": {
+        "description": "SSL private key file for subscription server",
+        "type": "string"
+      },
+      "subListen": {
+        "description": "Subscription server listen IP",
+        "type": "string"
+      },
+      "subPath": {
+        "description": "Base path for subscription URLs",
+        "type": "string"
+      },
+      "subPort": {
+        "description": "Subscription server port",
+        "maximum": 65535,
+        "minimum": 1,
+        "type": "integer"
+      },
+      "subProfileUrl": {
+        "description": "Subscription profile URL",
+        "type": "string"
+      },
+      "subRoutingRules": {
+        "description": "Subscription global routing rules (Only for Happ)",
+        "type": "string"
+      },
+      "subShowInfo": {
+        "description": "Show client information in subscriptions",
+        "type": "boolean"
+      },
+      "subSupportUrl": {
+        "description": "Subscription support URL",
+        "type": "string"
+      },
+      "subTitle": {
+        "description": "Subscription title",
+        "type": "string"
+      },
+      "subURI": {
+        "description": "Subscription server URI",
+        "type": "string"
+      },
+      "subUpdates": {
+        "description": "Subscription update interval in minutes",
+        "maximum": 525600,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "tgBotAPIServer": {
+        "description": "Custom API server for Telegram bot",
+        "type": "string"
+      },
+      "tgBotBackup": {
+        "description": "Enable database backup via Telegram",
+        "type": "boolean"
+      },
+      "tgBotChatId": {
+        "description": "Telegram chat ID for notifications",
+        "type": "string"
+      },
+      "tgBotEnable": {
+        "description": "Telegram bot settings\nEnable Telegram bot notifications",
+        "type": "boolean"
+      },
+      "tgBotLoginNotify": {
+        "description": "Send login notifications",
+        "type": "boolean"
+      },
+      "tgBotProxy": {
+        "description": "Proxy URL for Telegram bot",
+        "type": "string"
+      },
+      "tgBotToken": {
+        "description": "Telegram bot token",
+        "type": "string"
+      },
+      "tgCpu": {
+        "description": "CPU usage threshold for alerts (percent)",
+        "maximum": 100,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "tgLang": {
+        "description": "Telegram bot language",
+        "type": "string"
+      },
+      "tgRunTime": {
+        "description": "Cron schedule for Telegram notifications",
+        "type": "string"
+      },
+      "timeLocation": {
+        "description": "Security settings\nTime zone location",
+        "type": "string"
+      },
+      "trafficDiff": {
+        "description": "Traffic warning threshold percentage",
+        "maximum": 100,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "trustedProxyCIDRs": {
+        "description": "Trusted reverse proxy IPs/CIDRs for forwarded headers",
+        "type": "string"
+      },
+      "twoFactorEnable": {
+        "description": "Enable two-factor authentication",
+        "type": "boolean"
+      },
+      "twoFactorToken": {
+        "description": "Two-factor authentication token",
+        "type": "string"
+      },
+      "webBasePath": {
+        "description": "Base path for web panel URLs",
+        "type": "string"
+      },
+      "webCertFile": {
+        "description": "Path to SSL certificate file for web server",
+        "type": "string"
+      },
+      "webDomain": {
+        "description": "Web server domain for domain validation",
+        "type": "string"
+      },
+      "webKeyFile": {
+        "description": "Path to SSL private key file for web server",
+        "type": "string"
+      },
+      "webListen": {
+        "description": "Web server settings\nWeb server listen IP address",
+        "type": "string"
+      },
+      "webPort": {
+        "description": "Web server port number",
+        "maximum": 65535,
+        "minimum": 1,
+        "type": "integer"
+      }
+    },
+    "required": [
+      "datepicker",
+      "expireDiff",
+      "externalTrafficInformEnable",
+      "externalTrafficInformURI",
+      "hasApiToken",
+      "hasLdapPassword",
+      "hasNordSecret",
+      "hasTgBotToken",
+      "hasTwoFactorToken",
+      "hasWarpSecret",
+      "ldapAutoCreate",
+      "ldapAutoDelete",
+      "ldapBaseDN",
+      "ldapBindDN",
+      "ldapDefaultExpiryDays",
+      "ldapDefaultLimitIP",
+      "ldapDefaultTotalGB",
+      "ldapEnable",
+      "ldapFlagField",
+      "ldapHost",
+      "ldapInboundTags",
+      "ldapInvertFlag",
+      "ldapPassword",
+      "ldapPort",
+      "ldapSyncCron",
+      "ldapTruthyValues",
+      "ldapUseTLS",
+      "ldapUserAttr",
+      "ldapUserFilter",
+      "ldapVlessField",
+      "pageSize",
+      "panelProxy",
+      "remarkModel",
+      "restartXrayOnClientDisable",
+      "sessionMaxAge",
+      "subAnnounce",
+      "subCertFile",
+      "subClashEnable",
+      "subClashEnableRouting",
+      "subClashPath",
+      "subClashRules",
+      "subClashURI",
+      "subDomain",
+      "subEmailInRemark",
+      "subEnable",
+      "subEnableRouting",
+      "subEncrypt",
+      "subJsonEnable",
+      "subJsonFinalMask",
+      "subJsonMux",
+      "subJsonPath",
+      "subJsonRules",
+      "subJsonURI",
+      "subKeyFile",
+      "subListen",
+      "subPath",
+      "subPort",
+      "subProfileUrl",
+      "subRoutingRules",
+      "subShowInfo",
+      "subSupportUrl",
+      "subTitle",
+      "subURI",
+      "subUpdates",
+      "tgBotAPIServer",
+      "tgBotBackup",
+      "tgBotChatId",
+      "tgBotEnable",
+      "tgBotLoginNotify",
+      "tgBotProxy",
+      "tgBotToken",
+      "tgCpu",
+      "tgLang",
+      "tgRunTime",
+      "timeLocation",
+      "trafficDiff",
+      "trustedProxyCIDRs",
+      "twoFactorEnable",
+      "twoFactorToken",
+      "webBasePath",
+      "webCertFile",
+      "webDomain",
+      "webKeyFile",
+      "webListen",
+      "webPort"
+    ],
+    "type": "object"
+  },
+  "ApiToken": {
+    "properties": {
+      "createdAt": {
+        "type": "integer"
+      },
+      "enabled": {
+        "type": "boolean"
+      },
+      "id": {
+        "type": "integer"
+      },
+      "name": {
+        "type": "string"
+      },
+      "token": {
+        "description": "SHA-256 hash; the plaintext is shown only once at creation",
+        "type": "string"
+      }
+    },
+    "required": [
+      "createdAt",
+      "enabled",
+      "id",
+      "name",
+      "token"
+    ],
+    "type": "object"
+  },
+  "ApiTokenView": {
+    "properties": {
+      "createdAt": {
+        "example": 1736000000,
+        "type": "integer"
+      },
+      "enabled": {
+        "example": true,
+        "type": "boolean"
+      },
+      "id": {
+        "example": 2,
+        "type": "integer"
+      },
+      "name": {
+        "example": "central-panel-a",
+        "type": "string"
+      },
+      "token": {
+        "example": "new-token-string",
+        "type": "string"
+      }
+    },
+    "required": [
+      "createdAt",
+      "enabled",
+      "id",
+      "name"
+    ],
+    "type": "object"
+  },
+  "Client": {
+    "description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
+    "properties": {
+      "auth": {
+        "description": "Auth password (Hysteria)",
+        "type": "string"
+      },
+      "comment": {
+        "description": "Client comment",
+        "type": "string"
+      },
+      "created_at": {
+        "description": "Creation timestamp",
+        "type": "integer"
+      },
+      "email": {
+        "description": "Client email identifier",
+        "type": "string"
+      },
+      "enable": {
+        "description": "Whether the client is enabled",
+        "type": "boolean"
+      },
+      "expiryTime": {
+        "description": "Expiration timestamp",
+        "type": "integer"
+      },
+      "flow": {
+        "description": "Flow control (XTLS)",
+        "type": "string"
+      },
+      "group": {
+        "description": "Logical grouping label",
+        "type": "string"
+      },
+      "id": {
+        "description": "Unique client identifier",
+        "type": "string"
+      },
+      "limitIp": {
+        "description": "IP limit for this client",
+        "type": "integer"
+      },
+      "password": {
+        "description": "Client password",
+        "type": "string"
+      },
+      "reset": {
+        "description": "Reset period in days",
+        "type": "integer"
+      },
+      "reverse": {
+        "allOf": [
+          {
+            "$ref": "#/components/schemas/ClientReverse"
+          }
+        ],
+        "description": "VLESS simple reverse proxy settings",
+        "nullable": true
+      },
+      "security": {
+        "description": "Security method (e.g., \"auto\", \"aes-128-gcm\")",
+        "type": "string"
+      },
+      "subId": {
+        "description": "Subscription identifier",
+        "type": "string"
+      },
+      "tgId": {
+        "description": "Telegram user ID for notifications",
+        "type": "integer"
+      },
+      "totalGB": {
+        "description": "Total traffic limit in GB",
+        "type": "integer"
+      },
+      "updated_at": {
+        "description": "Last update timestamp",
+        "type": "integer"
+      }
+    },
+    "required": [
+      "comment",
+      "email",
+      "enable",
+      "expiryTime",
+      "limitIp",
+      "reset",
+      "security",
+      "subId",
+      "tgId",
+      "totalGB"
+    ],
+    "type": "object"
+  },
+  "ClientInbound": {
+    "properties": {
+      "clientId": {
+        "type": "integer"
+      },
+      "createdAt": {
+        "type": "integer"
+      },
+      "flowOverride": {
+        "type": "string"
+      },
+      "inboundId": {
+        "type": "integer"
+      }
+    },
+    "required": [
+      "clientId",
+      "createdAt",
+      "flowOverride",
+      "inboundId"
+    ],
+    "type": "object"
+  },
+  "ClientRecord": {
+    "properties": {
+      "auth": {
+        "type": "string"
+      },
+      "comment": {
+        "type": "string"
+      },
+      "createdAt": {
+        "type": "integer"
+      },
+      "email": {
+        "type": "string"
+      },
+      "enable": {
+        "type": "boolean"
+      },
+      "expiryTime": {
+        "type": "integer"
+      },
+      "flow": {
+        "type": "string"
+      },
+      "group": {
+        "type": "string"
+      },
+      "id": {
+        "type": "integer"
+      },
+      "limitIp": {
+        "type": "integer"
+      },
+      "password": {
+        "type": "string"
+      },
+      "reset": {
+        "type": "integer"
+      },
+      "reverse": {},
+      "security": {
+        "type": "string"
+      },
+      "subId": {
+        "type": "string"
+      },
+      "tgId": {
+        "type": "integer"
+      },
+      "totalGB": {
+        "type": "integer"
+      },
+      "updatedAt": {
+        "type": "integer"
+      },
+      "uuid": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "auth",
+      "comment",
+      "createdAt",
+      "email",
+      "enable",
+      "expiryTime",
+      "flow",
+      "group",
+      "id",
+      "limitIp",
+      "password",
+      "reset",
+      "reverse",
+      "security",
+      "subId",
+      "tgId",
+      "totalGB",
+      "updatedAt",
+      "uuid"
+    ],
+    "type": "object"
+  },
+  "ClientReverse": {
+    "properties": {
+      "tag": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "tag"
+    ],
+    "type": "object"
+  },
+  "ClientTraffic": {
+    "description": "ClientTraffic represents traffic statistics and limits for a specific client.\nIt tracks upload/download usage, expiry times, and online status for inbound clients.",
+    "properties": {
+      "down": {
+        "example": 2097152,
+        "type": "integer"
+      },
+      "email": {
+        "example": "user1",
+        "type": "string"
+      },
+      "enable": {
+        "example": true,
+        "type": "boolean"
+      },
+      "expiryTime": {
+        "example": 1735689600000,
+        "type": "integer"
+      },
+      "id": {
+        "example": 14825,
+        "type": "integer"
+      },
+      "inboundId": {
+        "example": 1,
+        "type": "integer"
+      },
+      "lastOnline": {
+        "example": 1735680000000,
+        "type": "integer"
+      },
+      "reset": {
+        "example": 0,
+        "type": "integer"
+      },
+      "subId": {
+        "example": "i7tvdpeffi0hvvf1",
+        "type": "string"
+      },
+      "total": {
+        "example": 10737418240,
+        "type": "integer"
+      },
+      "up": {
+        "example": 1048576,
+        "type": "integer"
+      },
+      "uuid": {
+        "example": "e18c9a96-71bf-48d4-933f-8b9a46d4290c",
+        "type": "string"
+      }
+    },
+    "required": [
+      "down",
+      "email",
+      "enable",
+      "expiryTime",
+      "id",
+      "inboundId",
+      "lastOnline",
+      "reset",
+      "subId",
+      "total",
+      "up",
+      "uuid"
+    ],
+    "type": "object"
+  },
+  "CustomGeoResource": {
+    "properties": {
+      "alias": {
+        "type": "string"
+      },
+      "createdAt": {
+        "type": "integer"
+      },
+      "id": {
+        "type": "integer"
+      },
+      "lastModified": {
+        "type": "string"
+      },
+      "lastUpdatedAt": {
+        "type": "integer"
+      },
+      "localPath": {
+        "type": "string"
+      },
+      "type": {
+        "type": "string"
+      },
+      "updatedAt": {
+        "type": "integer"
+      },
+      "url": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "alias",
+      "createdAt",
+      "id",
+      "lastModified",
+      "lastUpdatedAt",
+      "localPath",
+      "type",
+      "updatedAt",
+      "url"
+    ],
+    "type": "object"
+  },
+  "FallbackParentInfo": {
+    "description": "FallbackParentInfo carries everything the frontend needs to rewrite a\nchild inbound's client link: where to connect (the master's address\nand port) and which path matched on the master's fallbacks array.\nThe frontend already has the master inbound in its dbInbounds list,\nso we only ship identifiers + the match path here.",
+    "properties": {
+      "masterId": {
+        "type": "integer"
+      },
+      "path": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "masterId"
+    ],
+    "type": "object"
+  },
+  "HistoryOfSeeders": {
+    "description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
+    "properties": {
+      "id": {
+        "type": "integer"
+      },
+      "seederName": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "id",
+      "seederName"
+    ],
+    "type": "object"
+  },
+  "Inbound": {
+    "description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.",
+    "properties": {
+      "clientStats": {
+        "description": "Client traffic statistics",
+        "items": {
+          "$ref": "#/components/schemas/ClientTraffic"
+        },
+        "type": "array"
+      },
+      "down": {
+        "description": "Download traffic in bytes",
+        "type": "integer"
+      },
+      "enable": {
+        "description": "Whether the inbound is enabled",
+        "example": true,
+        "type": "boolean"
+      },
+      "expiryTime": {
+        "description": "Expiration timestamp",
+        "type": "integer"
+      },
+      "fallbackParent": {
+        "allOf": [
+          {
+            "$ref": "#/components/schemas/FallbackParentInfo"
+          }
+        ],
+        "description": "FallbackParent is populated by the API layer when this inbound is\nattached as a fallback child of a VLESS/Trojan TCP-TLS master.\nThe frontend uses it to rewrite client-share links so they advertise\nthe master's externally reachable endpoint instead of the child's\nloopback listen. Not persisted.",
+        "nullable": true
+      },
+      "id": {
+        "description": "Unique identifier",
+        "example": 1,
+        "type": "integer"
+      },
+      "lastTrafficResetTime": {
+        "description": "Last traffic reset timestamp",
+        "type": "integer"
+      },
+      "listen": {
+        "description": "Xray configuration fields",
+        "type": "string"
+      },
+      "nodeId": {
+        "nullable": true,
+        "type": "integer"
+      },
+      "originNodeGuid": {
+        "description": "OriginNodeGuid is the panelGuid of the node that physically hosts this\ninbound, propagated up across hops (#4983). Empty for an inbound that\nlives on this panel's own xray; set to the originating node's GUID when\nthe inbound was synced from a node (kept as-is across further hops). Lets\nthe master attribute a deeply nested inbound to the real node instead of\nthe intermediate one it was fetched through.",
+        "type": "string"
+      },
+      "port": {
+        "example": 443,
+        "maximum": 65535,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "protocol": {
+        "enum": [
+          "vmess",
+          "vless",
+          "trojan",
+          "shadowsocks",
+          "wireguard",
+          "hysteria",
+          "http",
+          "mixed",
+          "tunnel",
+          "tun"
+        ],
+        "example": "vless",
+        "type": "string"
+      },
+      "remark": {
+        "description": "Human-readable remark",
+        "example": "VLESS-443",
+        "type": "string"
+      },
+      "settings": {},
+      "sniffing": {},
+      "streamSettings": {},
+      "tag": {
+        "example": "in-443-tcp",
+        "type": "string"
+      },
+      "total": {
+        "description": "Total traffic limit in bytes",
+        "type": "integer"
+      },
+      "trafficReset": {
+        "description": "Traffic reset schedule",
+        "enum": [
+          "never",
+          "hourly",
+          "daily",
+          "weekly",
+          "monthly"
+        ],
+        "type": "string"
+      },
+      "up": {
+        "description": "Upload traffic in bytes",
+        "type": "integer"
+      }
+    },
+    "required": [
+      "clientStats",
+      "down",
+      "enable",
+      "expiryTime",
+      "id",
+      "lastTrafficResetTime",
+      "listen",
+      "port",
+      "protocol",
+      "remark",
+      "settings",
+      "sniffing",
+      "streamSettings",
+      "tag",
+      "total",
+      "trafficReset",
+      "up"
+    ],
+    "type": "object"
+  },
+  "InboundClientIps": {
+    "description": "InboundClientIps stores IP addresses associated with inbound clients for access control.",
+    "properties": {
+      "clientEmail": {
+        "type": "string"
+      },
+      "id": {
+        "type": "integer"
+      },
+      "ips": {}
+    },
+    "required": [
+      "clientEmail",
+      "id",
+      "ips"
+    ],
+    "type": "object"
+  },
+  "InboundFallback": {
+    "properties": {
+      "alpn": {
+        "type": "string"
+      },
+      "childId": {
+        "type": "integer"
+      },
+      "dest": {
+        "type": "string"
+      },
+      "id": {
+        "type": "integer"
+      },
+      "masterId": {
+        "type": "integer"
+      },
+      "name": {
+        "type": "string"
+      },
+      "path": {
+        "type": "string"
+      },
+      "sortOrder": {
+        "type": "integer"
+      },
+      "xver": {
+        "type": "integer"
+      }
+    },
+    "required": [
+      "alpn",
+      "childId",
+      "dest",
+      "id",
+      "masterId",
+      "name",
+      "path",
+      "sortOrder",
+      "xver"
+    ],
+    "type": "object"
+  },
+  "InboundOption": {
+    "properties": {
+      "id": {
+        "example": 1,
+        "type": "integer"
+      },
+      "port": {
+        "example": 443,
+        "type": "integer"
+      },
+      "protocol": {
+        "example": "vless",
+        "type": "string"
+      },
+      "remark": {
+        "example": "VLESS-443",
+        "type": "string"
+      },
+      "ssMethod": {
+        "type": "string"
+      },
+      "tag": {
+        "example": "in-443-tcp",
+        "type": "string"
+      },
+      "tlsFlowCapable": {
+        "example": true,
+        "type": "boolean"
+      }
+    },
+    "required": [
+      "id",
+      "port",
+      "protocol",
+      "remark",
+      "ssMethod",
+      "tag",
+      "tlsFlowCapable"
+    ],
+    "type": "object"
+  },
+  "Msg": {
+    "description": "Msg represents a standard API response message with success status, message text, and optional data object.",
+    "properties": {
+      "msg": {
+        "description": "Response message text",
+        "type": "string"
+      },
+      "obj": {
+        "description": "Optional data object"
+      },
+      "success": {
+        "description": "Indicates if the operation was successful",
+        "type": "boolean"
+      }
+    },
+    "required": [
+      "msg",
+      "obj",
+      "success"
+    ],
+    "type": "object"
+  },
+  "Node": {
+    "description": "Node represents a remote 3x-ui panel registered with the central panel.\nThe central panel polls each node's existing /panel/api/server/status\nendpoint over HTTP using the per-node ApiToken to populate the runtime\nstatus fields below.",
+    "properties": {
+      "address": {
+        "example": "node1.example.com",
+        "type": "string"
+      },
+      "allowPrivateAddress": {
+        "type": "boolean"
+      },
+      "apiToken": {
+        "example": "abcdef0123456789",
+        "type": "string"
+      },
+      "basePath": {
+        "example": "/",
+        "type": "string"
+      },
+      "clientCount": {
+        "example": 27,
+        "type": "integer"
+      },
+      "configDirty": {
+        "type": "boolean"
+      },
+      "configDirtyAt": {
+        "type": "integer"
+      },
+      "cpuPct": {
+        "example": 23.5,
+        "type": "number"
+      },
+      "createdAt": {
+        "example": 1700000000,
+        "type": "integer"
+      },
+      "depletedCount": {
+        "example": 1,
+        "type": "integer"
+      },
+      "enable": {
+        "example": true,
+        "type": "boolean"
+      },
+      "guid": {
+        "description": "Guid is the remote panel's stable self-identifier (its panelGuid),\nlearned from each heartbeat. It is the globally stable node identity used\nto attribute online clients/inbounds to the physical node across a chain\nof nodes (#4983); panel-local autoincrement ids don't survive a hop.\nObserved-state only — never user-edited.",
+        "type": "string"
+      },
+      "id": {
+        "example": 1,
+        "type": "integer"
+      },
+      "inboundCount": {
+        "example": 5,
+        "type": "integer"
+      },
+      "lastError": {
+        "type": "string"
+      },
+      "lastHeartbeat": {
+        "description": "unix seconds, 0 = never",
+        "example": 1700000000,
+        "type": "integer"
+      },
+      "latencyMs": {
+        "example": 42,
+        "type": "integer"
+      },
+      "memPct": {
+        "example": 45.1,
+        "type": "number"
+      },
+      "name": {
+        "example": "de-fra-1",
+        "type": "string"
+      },
+      "onlineCount": {
+        "example": 3,
+        "type": "integer"
+      },
+      "panelVersion": {
+        "example": "v3.x.x",
+        "type": "string"
+      },
+      "parentGuid": {
+        "description": "ParentGuid + Transitive are set only when a node is surfaced as part of a\nnode tree (#4983): direct nodes carry the master panel's own GUID, a\ntransitive sub-node carries its parent node's GUID. Transitive nodes are\nread-only projections (Id == 0, not persisted) — never edited or deployed.",
+        "type": "string"
+      },
+      "pinnedCertSha256": {
+        "type": "string"
+      },
+      "port": {
+        "example": 2053,
+        "maximum": 65535,
+        "minimum": 1,
+        "type": "integer"
+      },
+      "remark": {
+        "type": "string"
+      },
+      "scheme": {
+        "enum": [
+          "http",
+          "https"
+        ],
+        "example": "https",
+        "type": "string"
+      },
+      "status": {
+        "description": "Heartbeat-updated fields. UpdatedAt advances on every probe even when\nthe row is otherwise unchanged so the UI's \"last seen\" tooltip is\ntruthful without us having to read LastHeartbeat separately.\nonline|offline|unknown",
+        "example": "online",
+        "type": "string"
+      },
+      "tlsVerifyMode": {
+        "enum": [
+          "verify",
+          "skip",
+          "pin"
+        ],
+        "type": "string"
+      },
+      "transitive": {
+        "type": "boolean"
+      },
+      "updatedAt": {
+        "example": 1700000000,
+        "type": "integer"
+      },
+      "uptimeSecs": {
+        "example": 86400,
+        "type": "integer"
+      },
+      "xrayVersion": {
+        "example": "25.10.31",
+        "type": "string"
+      }
+    },
+    "required": [
+      "address",
+      "allowPrivateAddress",
+      "apiToken",
+      "basePath",
+      "clientCount",
+      "configDirty",
+      "configDirtyAt",
+      "cpuPct",
+      "createdAt",
+      "depletedCount",
+      "enable",
+      "guid",
+      "id",
+      "inboundCount",
+      "lastError",
+      "lastHeartbeat",
+      "latencyMs",
+      "memPct",
+      "name",
+      "onlineCount",
+      "panelVersion",
+      "pinnedCertSha256",
+      "port",
+      "remark",
+      "scheme",
+      "status",
+      "tlsVerifyMode",
+      "updatedAt",
+      "uptimeSecs",
+      "xrayVersion"
+    ],
+    "type": "object"
+  },
+  "OutboundTraffics": {
+    "description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
+    "properties": {
+      "down": {
+        "type": "integer"
+      },
+      "id": {
+        "type": "integer"
+      },
+      "tag": {
+        "type": "string"
+      },
+      "total": {
+        "type": "integer"
+      },
+      "up": {
+        "type": "integer"
+      }
+    },
+    "required": [
+      "down",
+      "id",
+      "tag",
+      "total",
+      "up"
+    ],
+    "type": "object"
+  },
+  "ProbeResultUI": {
+    "properties": {
+      "cpuPct": {
+        "example": 12.5,
+        "type": "number"
+      },
+      "error": {
+        "type": "string"
+      },
+      "latencyMs": {
+        "example": 42,
+        "type": "integer"
+      },
+      "memPct": {
+        "example": 45.2,
+        "type": "number"
+      },
+      "panelVersion": {
+        "example": "v3.x.x",
+        "type": "string"
+      },
+      "status": {
+        "example": "online",
+        "type": "string"
+      },
+      "uptimeSecs": {
+        "example": 86400,
+        "type": "integer"
+      },
+      "xrayVersion": {
+        "example": "25.10.31",
+        "type": "string"
+      }
+    },
+    "required": [
+      "cpuPct",
+      "error",
+      "latencyMs",
+      "memPct",
+      "panelVersion",
+      "status",
+      "uptimeSecs",
+      "xrayVersion"
+    ],
+    "type": "object"
+  },
+  "Setting": {
+    "description": "Setting stores key-value configuration settings for the 3x-ui panel.",
+    "properties": {
+      "id": {
+        "type": "integer"
+      },
+      "key": {
+        "type": "string"
+      },
+      "value": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "id",
+      "key",
+      "value"
+    ],
+    "type": "object"
+  },
+  "User": {
+    "description": "User represents a user account in the 3x-ui panel.",
+    "properties": {
+      "id": {
+        "type": "integer"
+      },
+      "password": {
+        "type": "string"
+      },
+      "username": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "id",
+      "password",
+      "username"
+    ],
+    "type": "object"
+  }
+};

+ 37 - 0
frontend/src/generated/types.ts

@@ -1,5 +1,9 @@
 // Code generated by tools/openapigen. DO NOT EDIT.
+export type LoginStatus = number;
+export type ProcessState = string;
 export type Protocol = string;
+export type SubLinkProvider = unknown;
+export type transportBits = number;
 
 export interface AllSetting {
   datepicker: string;
@@ -179,6 +183,14 @@ export interface ApiToken {
   token: string;
 }
 
+export interface ApiTokenView {
+  createdAt: number;
+  enabled: boolean;
+  id: number;
+  name: string;
+  token?: string;
+}
+
 export interface Client {
   auth?: string;
   comment: string;
@@ -280,6 +292,7 @@ export interface Inbound {
   lastTrafficResetTime: number;
   listen: string;
   nodeId?: number | null;
+  originNodeGuid?: string;
   port: number;
   protocol: Protocol;
   remark: string;
@@ -310,6 +323,16 @@ export interface InboundFallback {
   xver: number;
 }
 
+export interface InboundOption {
+  id: number;
+  port: number;
+  protocol: string;
+  remark: string;
+  ssMethod: string;
+  tag: string;
+  tlsFlowCapable: boolean;
+}
+
 export interface Msg {
   msg: string;
   obj: unknown;
@@ -328,6 +351,7 @@ export interface Node {
   createdAt: number;
   depletedCount: number;
   enable: boolean;
+  guid: string;
   id: number;
   inboundCount: number;
   lastError: string;
@@ -337,12 +361,14 @@ export interface Node {
   name: string;
   onlineCount: number;
   panelVersion: string;
+  parentGuid?: string;
   pinnedCertSha256: string;
   port: number;
   remark: string;
   scheme: string;
   status: string;
   tlsVerifyMode: string;
+  transitive?: boolean;
   updatedAt: number;
   uptimeSecs: number;
   xrayVersion: string;
@@ -356,6 +382,17 @@ export interface OutboundTraffics {
   up: number;
 }
 
+export interface ProbeResultUI {
+  cpuPct: number;
+  error: string;
+  latencyMs: number;
+  memPct: number;
+  panelVersion: string;
+  status: string;
+  uptimeSecs: number;
+  xrayVersion: string;
+}
+
 export interface Setting {
   id: number;
   key: string;

+ 48 - 0
frontend/src/generated/zod.ts

@@ -1,8 +1,20 @@
 // Code generated by tools/openapigen. DO NOT EDIT.
 import { z } from 'zod';
+export const LoginStatusSchema = z.number().int();
+export type LoginStatus = z.infer<typeof LoginStatusSchema>;
+
+export const ProcessStateSchema = z.string();
+export type ProcessState = z.infer<typeof ProcessStateSchema>;
+
 export const ProtocolSchema = z.string();
 export type Protocol = z.infer<typeof ProtocolSchema>;
 
+export const SubLinkProviderSchema = z.unknown();
+export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
+
+export const transportBitsSchema = z.number().int();
+export type transportBits = z.infer<typeof transportBitsSchema>;
+
 export const AllSettingSchema = z.object({
   datepicker: z.string(),
   expireDiff: z.number().int().min(0),
@@ -184,6 +196,15 @@ export const ApiTokenSchema = z.object({
 });
 export type ApiToken = z.infer<typeof ApiTokenSchema>;
 
+export const ApiTokenViewSchema = z.object({
+  createdAt: z.number().int(),
+  enabled: z.boolean(),
+  id: z.number().int(),
+  name: z.string(),
+  token: z.string().optional(),
+});
+export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
+
 export const ClientSchema = z.object({
   auth: z.string().optional(),
   comment: z.string(),
@@ -293,6 +314,7 @@ export const InboundSchema = z.object({
   lastTrafficResetTime: z.number().int(),
   listen: z.string(),
   nodeId: z.number().int().nullable().optional(),
+  originNodeGuid: z.string().optional(),
   port: z.number().int().min(0).max(65535),
   protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']),
   remark: z.string(),
@@ -326,6 +348,17 @@ export const InboundFallbackSchema = z.object({
 });
 export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
 
+export const InboundOptionSchema = z.object({
+  id: z.number().int(),
+  port: z.number().int(),
+  protocol: z.string(),
+  remark: z.string(),
+  ssMethod: z.string(),
+  tag: z.string(),
+  tlsFlowCapable: z.boolean(),
+});
+export type InboundOption = z.infer<typeof InboundOptionSchema>;
+
 export const MsgSchema = z.object({
   msg: z.string(),
   obj: z.unknown(),
@@ -345,6 +378,7 @@ export const NodeSchema = z.object({
   createdAt: z.number().int(),
   depletedCount: z.number().int(),
   enable: z.boolean(),
+  guid: z.string(),
   id: z.number().int(),
   inboundCount: z.number().int(),
   lastError: z.string(),
@@ -354,12 +388,14 @@ export const NodeSchema = z.object({
   name: z.string(),
   onlineCount: z.number().int(),
   panelVersion: z.string(),
+  parentGuid: z.string().optional(),
   pinnedCertSha256: z.string(),
   port: z.number().int().min(1).max(65535),
   remark: z.string(),
   scheme: z.enum(['http', 'https']),
   status: z.string(),
   tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
+  transitive: z.boolean().optional(),
   updatedAt: z.number().int(),
   uptimeSecs: z.number().int(),
   xrayVersion: z.string(),
@@ -375,6 +411,18 @@ export const OutboundTrafficsSchema = z.object({
 });
 export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
 
+export const ProbeResultUISchema = z.object({
+  cpuPct: z.number(),
+  error: z.string(),
+  latencyMs: z.number().int(),
+  memPct: z.number(),
+  panelVersion: z.string(),
+  status: z.string(),
+  uptimeSecs: z.number().int(),
+  xrayVersion: z.string(),
+});
+export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
+
 export const SettingSchema = z.object({
   id: z.number().int(),
   key: z.string(),

+ 1 - 1
frontend/src/hooks/useClients.ts

@@ -142,7 +142,7 @@ async function fetchInboundOptions(): Promise<InboundOption[]> {
 }
 
 async function fetchDefaults(): Promise<Record<string, unknown>> {
-  const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
+  const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
   const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
   return validated.obj || {};

+ 1 - 1
frontend/src/hooks/useDatepicker.ts

@@ -22,7 +22,7 @@ async function loadOnce(): Promise<void> {
   }
   pending = (async () => {
     try {
-      const msg = await HttpUtil.post('/panel/setting/defaultSettings');
+      const msg = await HttpUtil.post('/panel/api/setting/defaultSettings');
       if (msg?.success) {
         const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
         cachedValue = validated.obj?.datepicker || 'gregorian';

+ 7 - 7
frontend/src/hooks/useXraySetting.ts

@@ -72,7 +72,7 @@ export interface UseXraySettingResult {
 type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
 
 async function fetchXrayConfig(): Promise<XrayConfigPayload> {
-  const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true });
+  const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
   if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
   let parsed: unknown;
@@ -91,7 +91,7 @@ async function fetchXrayConfig(): Promise<XrayConfigPayload> {
 }
 
 async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
-  const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true });
+  const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
   const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
   return Array.isArray(validated.obj) ? validated.obj : [];
@@ -200,7 +200,7 @@ export function useXraySetting(): UseXraySettingResult {
     mutationFn: async () => {
       const sentXraySetting = xraySettingRef.current;
       const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
-      const msg = await HttpUtil.post('/panel/xray/update', {
+      const msg = await HttpUtil.post('/panel/api/xray/update', {
         xraySetting: sentXraySetting,
         outboundTestUrl: sentTestUrl,
       });
@@ -217,7 +217,7 @@ export function useXraySetting(): UseXraySettingResult {
 
   const resetTrafficMut = useMutation({
     mutationFn: (tag: string) =>
-      HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }),
+      HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
     onSuccess: (msg) => {
       if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
     },
@@ -228,7 +228,7 @@ export function useXraySetting(): UseXraySettingResult {
       const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
       if (!msg?.success) return msg;
       await PromiseUtil.sleep(500);
-      const r = await HttpUtil.get('/panel/xray/getXrayResult');
+      const r = await HttpUtil.get('/panel/api/xray/getXrayResult');
       const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
       if (validated?.success) setRestartResult(validated.obj || '');
       return msg;
@@ -237,7 +237,7 @@ export function useXraySetting(): UseXraySettingResult {
 
   const resetDefaultMut = useMutation({
     mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
-      const raw = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
+      const raw = await HttpUtil.get('/panel/api/setting/getDefaultJsonConfig');
       return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
     },
     onSuccess: (msg) => {
@@ -264,7 +264,7 @@ export function useXraySetting(): UseXraySettingResult {
         [index]: { testing: true, result: null, mode: effMode },
       }));
       try {
-        const raw = await HttpUtil.post('/panel/xray/testOutbound', {
+        const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
           outbound: JSON.stringify(outbound),
           allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
           mode: effMode,

+ 1 - 0
frontend/src/pages/api-docs/ApiDocsPage.tsx

@@ -33,6 +33,7 @@ export default function ApiDocsPage() {
                 docExpansion="list"
                 deepLinking={false}
                 tryItOutEnabled
+                persistAuthorization
               />
             </div>
           </Layout.Content>

+ 38 - 35
frontend/src/pages/api-docs/endpoints.ts

@@ -38,6 +38,8 @@ export interface Endpoint {
   response?: string;
   errorResponse?: string;
   errorStatus?: number;
+  responseSchema?: string;
+  responseSchemaArray?: boolean;
 }
 
 export interface SubscriptionHeader {
@@ -107,22 +109,22 @@ export const sections: readonly Section[] = [
         method: 'GET',
         path: '/panel/api/inbounds/list',
         summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.',
-        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": {\n        "clients": [],\n        "decryption": "none"\n      },\n      "streamSettings": {\n        "network": "tcp",\n        "security": "reality",\n        "realitySettings": { "show": false, "dest": "..." }\n      },\n      "tag": "inbound-443",\n      "sniffing": {\n        "enabled": true,\n        "destOverride": ["http", "tls"]\n      },\n      "clientStats": []\n    }\n  ]\n}',
+        responseSchema: 'Inbound',
+        responseSchemaArray: true,
       },
       {
         method: 'GET',
         path: '/panel/api/inbounds/list/slim',
         summary: 'Same shape as /list but with settings.clients[] stripped down to {email, enable, comment} and ClientStats not enriched with UUID/SubId. Use this for list pages; fetch /get/:id when you need the full per-client payload (uuid, password, flow, ...).',
         response:
-          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "userId": 1,\n      "remark": "VLESS-443",\n      "settings": {\n        "clients": [\n          { "email": "alice", "enable": true }\n        ],\n        "decryption": "none"\n      },\n      "clientStats": []\n    }\n  ]\n}',
+          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "remark": "VLESS-443",\n      "settings": {\n        "clients": [\n          { "email": "alice", "enable": true }\n        ],\n        "decryption": "none"\n      },\n      "clientStats": []\n    }\n  ]\n}',
       },
       {
         method: 'GET',
         path: '/panel/api/inbounds/options',
-        summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns only id, remark, protocol, port, and a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
-        response:
-          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "remark": "VLESS-443",\n      "protocol": "vless",\n      "port": 443,\n      "tlsFlowCapable": true\n    }\n  ]\n}',
+        summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality), and ssMethod (the Shadowsocks cipher, empty for non-Shadowsocks inbounds — used by the client UI to generate a valid Shadowsocks 2022 PSK). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
+        responseSchema: 'InboundOption',
+        responseSchemaArray: true,
       },
       {
         method: 'GET',
@@ -696,7 +698,7 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/clients/activeInbounds',
         summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by the hosting node\'s panelGuid. Pairs with onlinesByGuid so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
-        response: '{\n  "success": true,\n  "obj": {\n    "a1b2-...": ["inbound-443", "inbound-8443"]\n  }\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "a1b2-...": ["in-443-tcp", "in-8443-tcp"]\n  }\n}',
       },
       {
         method: 'POST',
@@ -711,7 +713,7 @@ export const sections: readonly Section[] = [
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
         ],
-        response: '{\n  "success": true,\n  "obj": {\n    "email": "user1",\n    "up": 1048576,\n    "down": 2097152,\n    "total": 10737418240,\n    "expiryTime": 1735689600000\n  }\n}',
+        responseSchema: 'ClientTraffic',
       },
       {
         method: 'GET',
@@ -748,7 +750,8 @@ export const sections: readonly Section[] = [
         method: 'GET',
         path: '/panel/api/nodes/list',
         summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
-        response: '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "name": "de-fra-1",\n      "remark": "",\n      "scheme": "https",\n      "address": "node1.example.com",\n      "port": 2053,\n      "basePath": "/",\n      "apiToken": "abcdef...",\n      "enable": true,\n      "allowPrivateAddress": false,\n      "status": "online",\n      "lastHeartbeat": 1700000000,\n      "latencyMs": 42,\n      "xrayVersion": "25.x.x",\n      "panelVersion": "v3.x.x",\n      "cpuPct": 23.5,\n      "memPct": 45.1,\n      "uptimeSecs": 86400,\n      "lastError": "",\n      "inboundCount": 5,\n      "clientCount": 27,\n      "onlineCount": 3,\n      "depletedCount": 1,\n      "createdAt": 1700000000,\n      "updatedAt": 1700000000\n    }\n  ]\n}',
+        responseSchema: 'Node',
+        responseSchemaArray: true,
       },
       {
         method: 'GET',
@@ -805,7 +808,7 @@ export const sections: readonly Section[] = [
         path: '/panel/api/nodes/test',
         summary: 'Probe a node without saving it. Uses the body as connection details and returns the same heartbeat snapshot a registered node would have.',
         body: '{\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef..."\n}',
-        response: '{\n  "success": true,\n  "obj": {\n    "status": "online",\n    "latencyMs": 42,\n    "xrayVersion": "25.x.x",\n    "panelVersion": "v3.x.x",\n    "cpuPct": 12.5,\n    "memPct": 45.2,\n    "uptimeSecs": 86400,\n    "error": ""\n  }\n}',
+        responseSchema: 'ProbeResultUI',
       },
       {
         method: 'POST',
@@ -914,28 +917,28 @@ export const sections: readonly Section[] = [
     id: 'settings',
     title: 'Settings',
     description:
-      'Panel configuration and user credentials. All endpoints live under /panel/setting and require a logged-in session or Bearer token.',
+      'Panel configuration and user credentials. All endpoints live under /panel/api/setting and require a logged-in session or Bearer token.',
     endpoints: [
       {
         method: 'POST',
-        path: '/panel/setting/all',
+        path: '/panel/api/setting/all',
         summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
         response: '{\n  "success": true,\n  "obj": {\n    "webPort": 2053,\n    "webCertFile": "",\n    "webKeyFile": "",\n    "webBasePath": "/",\n    "subPort": 10882,\n    "subPath": "/sub/",\n    "tgBotEnable": false,\n    "tgBotToken": "",\n    ...\n  }\n}',
       },
       {
         method: 'POST',
-        path: '/panel/setting/defaultSettings',
+        path: '/panel/api/setting/defaultSettings',
         summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
       },
       {
         method: 'POST',
-        path: '/panel/setting/update',
+        path: '/panel/api/setting/update',
         summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
         body: '{\n  "webPort": 2053,\n  "webBasePath": "/",\n  "subPort": 10882,\n  "subPath": "/sub/",\n  "tgBotEnable": false,\n  ...\n}',
       },
       {
         method: 'POST',
-        path: '/panel/setting/updateUser',
+        path: '/panel/api/setting/updateUser',
         summary: 'Change the panel admin username and password. Requires the current credentials for verification. The session is refreshed with the new values on success.',
         params: [
           { name: 'oldUsername', in: 'body', type: 'string', desc: 'Current admin username.' },
@@ -947,12 +950,12 @@ export const sections: readonly Section[] = [
       },
       {
         method: 'POST',
-        path: '/panel/setting/restartPanel',
+        path: '/panel/api/setting/restartPanel',
         summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
       },
       {
         method: 'GET',
-        path: '/panel/setting/getDefaultJsonConfig',
+        path: '/panel/api/setting/getDefaultJsonConfig',
         summary: 'Return the built-in default Xray JSON config template that ships with this panel version.',
       },
     ],
@@ -962,28 +965,28 @@ export const sections: readonly Section[] = [
     id: 'api-tokens',
     title: 'API Tokens',
     description:
-      'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request.',
+      'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request — the token is a full-admin credential.',
     endpoints: [
       {
         method: 'GET',
-        path: '/panel/setting/apiTokens',
+        path: '/panel/api/setting/apiTokens',
         summary: 'List every API token, enabled or not. The token value is never returned — only metadata.',
         response: '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "name": "default",\n      "enabled": true,\n      "createdAt": 1736000000\n    }\n  ]\n}',
       },
       {
         method: 'POST',
-        path: '/panel/setting/apiTokens/create',
+        path: '/panel/api/setting/apiTokens/create',
         summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.',
         params: [
           { name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
         ],
         body: '{\n  "name": "central-panel-a"\n}',
-        response: '{\n  "success": true,\n  "obj": {\n    "id": 2,\n    "name": "central-panel-a",\n    "token": "new-token-string",\n    "enabled": true,\n    "createdAt": 1736000000\n  }\n}',
+        responseSchema: 'ApiTokenView',
         errorResponse: '{\n  "success": false,\n  "msg": "a token with that name already exists"\n}',
       },
       {
         method: 'POST',
-        path: '/panel/setting/apiTokens/delete/:id',
+        path: '/panel/api/setting/apiTokens/delete/:id',
         summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.',
         params: [
           { name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
@@ -992,7 +995,7 @@ export const sections: readonly Section[] = [
       },
       {
         method: 'POST',
-        path: '/panel/setting/apiTokens/setEnabled/:id',
+        path: '/panel/api/setting/apiTokens/setEnabled/:id',
         summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.',
         params: [
           { name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
@@ -1008,32 +1011,32 @@ export const sections: readonly Section[] = [
     id: 'xray-settings',
     title: 'Xray Settings',
     description:
-      'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.',
+      'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/api/xray.',
     endpoints: [
       {
         method: 'POST',
-        path: '/panel/xray/',
+        path: '/panel/api/xray/',
         summary: 'Return the Xray config template (JSON string), available inbound tags, client reverse tags, and the configured outbound test URL in one response.',
-        response: '{\n  "success": true,\n  "obj": {\n    "xraySetting": "{...raw xray config...}",\n    "inboundTags": "[\\"inbound-443\\"]",\n    "clientReverseTags": "[]",\n    "outboundTestUrl": "https://www.google.com/generate_204"\n  }\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "xraySetting": "{...raw xray config...}",\n    "inboundTags": "[\\"in-443-tcp\\"]",\n    "clientReverseTags": "[]",\n    "outboundTestUrl": "https://www.google.com/generate_204"\n  }\n}',
       },
       {
         method: 'GET',
-        path: '/panel/xray/getDefaultJsonConfig',
-        summary: 'Return the built-in default Xray config shipped with the panel (identical to /panel/setting/getDefaultJsonConfig).',
+        path: '/panel/api/xray/getDefaultJsonConfig',
+        summary: 'Return the built-in default Xray config shipped with the panel (identical to /panel/api/setting/getDefaultJsonConfig).',
       },
       {
         method: 'GET',
-        path: '/panel/xray/getOutboundsTraffic',
+        path: '/panel/api/xray/getOutboundsTraffic',
         summary: 'Return traffic statistics for every outbound. Each outbound shows up/down/total counters.',
       },
       {
         method: 'GET',
-        path: '/panel/xray/getXrayResult',
+        path: '/panel/api/xray/getXrayResult',
         summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.',
       },
       {
         method: 'POST',
-        path: '/panel/xray/update',
+        path: '/panel/api/xray/update',
         summary: 'Save the Xray JSON config template and optionally the outbound test URL. Both are sent as form fields.',
         params: [
           { name: 'xraySetting', in: 'body (form)', type: 'string', desc: 'Full Xray JSON config template.' },
@@ -1042,7 +1045,7 @@ export const sections: readonly Section[] = [
       },
       {
         method: 'POST',
-        path: '/panel/xray/warp/:action',
+        path: '/panel/api/xray/warp/:action',
         summary: 'Manage Cloudflare Warp integration. The action parameter selects the operation.',
         params: [
           { name: 'action', in: 'path', type: 'string', desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).' },
@@ -1053,7 +1056,7 @@ export const sections: readonly Section[] = [
       },
       {
         method: 'POST',
-        path: '/panel/xray/nord/:action',
+        path: '/panel/api/xray/nord/:action',
         summary: 'Manage NordVPN integration. The action parameter selects the operation.',
         params: [
           { name: 'action', in: 'path', type: 'string', desc: 'countries — list available countries. servers — list servers in a country (sends countryId). reg — get NordVPN credentials (sends token). setKey — store NordVPN API key (sends key). data — return current NordVPN connection data. del — delete NordVPN data.' },
@@ -1064,7 +1067,7 @@ export const sections: readonly Section[] = [
       },
       {
         method: 'POST',
-        path: '/panel/xray/resetOutboundsTraffic',
+        path: '/panel/api/xray/resetOutboundsTraffic',
         summary: 'Reset traffic counters for a specific outbound by tag.',
         params: [
           { name: 'tag', in: 'body (form)', type: 'string', desc: 'Outbound tag to reset (e.g. "proxy", "direct").' },
@@ -1073,7 +1076,7 @@ export const sections: readonly Section[] = [
       },
       {
         method: 'POST',
-        path: '/panel/xray/testOutbound',
+        path: '/panel/api/xray/testOutbound',
         summary: 'Test an outbound configuration. Sends the outbound JSON (required), optionally all outbounds (to resolve sockopt.dialerProxy dependencies), and a mode flag.',
         params: [
           { name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound to test (required).' },

+ 12 - 1
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -89,6 +89,15 @@ export default function ClientBulkAddModal({
     [form.inboundIds, flowCapableIds],
   );
 
+  const ss2022Method = useMemo(() => {
+    for (const id of form.inboundIds || []) {
+      const ib = (inbounds || []).find((row) => row.id === id);
+      const method = ib?.ssMethod;
+      if (method && method.substring(0, 4) === '2022') return method;
+    }
+    return '';
+  }, [form.inboundIds, inbounds]);
+
   useEffect(() => {
     if (!showFlow && form.flow) {
 
@@ -153,7 +162,9 @@ export default function ClientBulkAddModal({
           email,
           subId: form.subId || RandomUtil.randomLowerAndNum(16),
           id: RandomUtil.randomUUID(),
-          password: RandomUtil.randomLowerAndNum(16),
+          password: ss2022Method
+            ? RandomUtil.randomShadowsocksPassword(ss2022Method)
+            : RandomUtil.randomLowerAndNum(16),
           auth: RandomUtil.randomLowerAndNum(16),
           flow: showFlow ? (form.flow || '') : '',
           totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),

+ 25 - 1
frontend/src/pages/clients/ClientFormModal.tsx

@@ -228,6 +228,21 @@ export default function ClientFormModal({
     return ids;
   }, [inbounds]);
 
+  const ss2022Method = useMemo(() => {
+    for (const id of form.inboundIds || []) {
+      const ib = (inbounds || []).find((row) => row.id === id);
+      const method = ib?.ssMethod;
+      if (method && method.substring(0, 4) === '2022') return method;
+    }
+    return '';
+  }, [form.inboundIds, inbounds]);
+
+  function regeneratePassword() {
+    update('password', ss2022Method
+      ? RandomUtil.randomShadowsocksPassword(ss2022Method)
+      : RandomUtil.randomLowerAndNum(16));
+  }
+
   const showFlow = useMemo(
     () => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
     [form.inboundIds, flowCapableIds],
@@ -257,6 +272,15 @@ export default function ClientFormModal({
     }
   }, [showReverseTag, form.reverseTag]);
 
+  useEffect(() => {
+    if (!ss2022Method) return;
+    setForm((prev) => (
+      RandomUtil.isShadowsocks2022Password(prev.password, ss2022Method)
+        ? prev
+        : { ...prev, password: RandomUtil.randomShadowsocksPassword(ss2022Method) }
+    ));
+  }, [ss2022Method]);
+
   const inboundOptions = useMemo(
     () => (inbounds || [])
       .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
@@ -433,7 +457,7 @@ export default function ClientFormModal({
               <Form.Item label={t('pages.clients.password')}>
                 <Space.Compact style={{ display: 'flex' }}>
                   <Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
-                  <Button icon={<ReloadOutlined />} onClick={() => update('password', RandomUtil.randomLowerAndNum(16))} />
+                  <Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
                 </Space.Compact>
               </Form.Item>
             </Col>

+ 1 - 1
frontend/src/pages/inbounds/form/useSecurityActions.ts

@@ -120,7 +120,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
       // node's own paths (fetched through the central panel), not this panel's.
       const msg = typeof nodeId === 'number'
         ? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
-        : await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
+        : await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
       if (!msg?.success) {
         messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
         return;

+ 1 - 1
frontend/src/pages/inbounds/useInbounds.ts

@@ -97,7 +97,7 @@ async function fetchLastOnlineMap(): Promise<Record<string, number>> {
 }
 
 async function fetchDefaultSettings(): Promise<DefaultsPayload> {
-  const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
+  const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
   const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
   return validated.obj ?? {};

+ 1 - 1
frontend/src/pages/index/BackupModal.tsx

@@ -52,7 +52,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
       }
 
       onBusy({ busy: true, tip: `${t('pages.settings.restartPanel')}…` });
-      const restart = await HttpUtil.post('/panel/setting/restartPanel');
+      const restart = await HttpUtil.post('/panel/api/setting/restartPanel');
       if (restart?.success) {
         await PromiseUtil.sleep(5000);
         window.location.reload();

+ 1 - 1
frontend/src/pages/index/IndexPage.tsx

@@ -87,7 +87,7 @@ export default function IndexPage() {
   const [loadingTip, setLoadingTip] = useState(t('loading'));
 
   useEffect(() => {
-    HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/setting/defaultSettings').then((msg) => {
+    HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
       if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
     });
     HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {

+ 5 - 5
frontend/src/pages/settings/SecurityTab.tsx

@@ -96,7 +96,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
   const sendUpdateUser = useCallback(async () => {
     setUpdating(true);
     try {
-      const msg = await HttpUtil.post('/panel/setting/updateUser', user) as ApiMsg;
+      const msg = await HttpUtil.post('/panel/api/setting/updateUser', user) as ApiMsg;
       if (msg?.success) {
         await HttpUtil.post('/logout');
         const basePath = window.X_UI_BASE_PATH || '/';
@@ -124,7 +124,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
   const loadApiTokens = useCallback(async () => {
     setApiTokensLoading(true);
     try {
-      const msg = await HttpUtil.get('/panel/setting/apiTokens') as ApiMsg<ApiTokenRow[]>;
+      const msg = await HttpUtil.get('/panel/api/setting/apiTokens') as ApiMsg<ApiTokenRow[]>;
       if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []);
     } finally {
       setApiTokensLoading(false);
@@ -156,7 +156,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
     }
     setCreating(true);
     try {
-      const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>;
+      const msg = await HttpUtil.post('/panel/api/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>;
       if (msg?.success) {
         setCreateOpen(false);
         await loadApiTokens();
@@ -178,7 +178,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
       cancelText: t('cancel'),
       okType: 'danger',
       onOk: async () => {
-        const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`) as ApiMsg;
+        const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/delete/${row.id}`) as ApiMsg;
         if (msg?.success) await loadApiTokens();
       },
     });
@@ -186,7 +186,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
 
   async function toggleTokenEnabled(row: ApiTokenRow) {
     const target = !row.enabled;
-    const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) as ApiMsg;
+    const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) as ApiMsg;
     if (msg?.success) {
       setApiTokens((prev) => prev.map((r) => (r.id === row.id ? { ...r, enabled: target } : r)));
     }

+ 1 - 1
frontend/src/pages/settings/SettingsPage.tsx

@@ -142,7 +142,7 @@ export default function SettingsPage() {
       onOk: async () => {
         setSpinning(true);
         try {
-          const msg = await HttpUtil.post('/panel/setting/restartPanel') as ApiMsg;
+          const msg = await HttpUtil.post('/panel/api/setting/restartPanel') as ApiMsg;
           if (!msg?.success) return;
           await PromiseUtil.sleep(5000);
           window.location.replace(rebuildUrlAfterRestart());

+ 6 - 6
frontend/src/pages/xray/overrides/NordModal.tsx

@@ -88,14 +88,14 @@ export default function NordModal({
   }, [filteredServers]);
 
   const fetchCountries = useCallback(async () => {
-    const msg = await HttpUtil.post<string>('/panel/xray/nord/countries');
+    const msg = await HttpUtil.post<string>('/panel/api/xray/nord/countries');
     if (msg?.success && msg.obj) setCountries(JSON.parse(msg.obj));
   }, []);
 
   const fetchData = useCallback(async () => {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post<string>('/panel/xray/nord/data');
+      const msg = await HttpUtil.post<string>('/panel/api/xray/nord/data');
       if (msg?.success) {
         const next = msg.obj ? JSON.parse(msg.obj) : null;
         setNordData(next);
@@ -113,7 +113,7 @@ export default function NordModal({
   async function login() {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post<string>('/panel/xray/nord/reg', { token });
+      const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token });
       if (msg?.success && msg.obj) {
         setNordData(JSON.parse(msg.obj));
         await fetchCountries();
@@ -126,7 +126,7 @@ export default function NordModal({
   async function saveKey() {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post<string>('/panel/xray/nord/setKey', { key: manualKey });
+      const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: manualKey });
       if (msg?.success && msg.obj) {
         setNordData(JSON.parse(msg.obj));
         await fetchCountries();
@@ -139,7 +139,7 @@ export default function NordModal({
   async function logout() {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post('/panel/xray/nord/del');
+      const msg = await HttpUtil.post('/panel/api/xray/nord/del');
       if (msg?.success) {
         onRemoveOutbound(nordOutboundIndex);
         onRemoveRoutingRules({ prefix: 'nord-' });
@@ -166,7 +166,7 @@ export default function NordModal({
     setServerId(null);
     setCityId(null);
     try {
-      const msg = await HttpUtil.post<string>('/panel/xray/nord/servers', { countryId: newCountryId });
+      const msg = await HttpUtil.post<string>('/panel/api/xray/nord/servers', { countryId: newCountryId });
       if (!msg?.success || !msg.obj) return;
       const data = JSON.parse(msg.obj);
       const locations = data.locations || [];

+ 5 - 5
frontend/src/pages/xray/overrides/WarpModal.tsx

@@ -111,7 +111,7 @@ export default function WarpModal({
   const fetchData = useCallback(async () => {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post<string>('/panel/xray/warp/data');
+      const msg = await HttpUtil.post<string>('/panel/api/xray/warp/data');
       if (msg?.success) {
         const raw = msg.obj;
         setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
@@ -133,7 +133,7 @@ export default function WarpModal({
     setLoading(true);
     try {
       const keys = Wireguard.generateKeypair();
-      const msg = await HttpUtil.post<string>('/panel/xray/warp/reg', keys);
+      const msg = await HttpUtil.post<string>('/panel/api/xray/warp/reg', keys);
       if (msg?.success && msg.obj) {
         const resp = JSON.parse(msg.obj);
         setWarpData(resp.data);
@@ -148,7 +148,7 @@ export default function WarpModal({
   async function getConfig() {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post<string>('/panel/xray/warp/config');
+      const msg = await HttpUtil.post<string>('/panel/api/xray/warp/config');
       if (msg?.success && msg.obj) {
         const parsed = JSON.parse(msg.obj);
         setWarpConfig(parsed);
@@ -164,7 +164,7 @@ export default function WarpModal({
     setLoading(true);
     setLicenseError('');
     try {
-      const msg = await HttpUtil.post<string>('/panel/xray/warp/license', { license: warpPlus });
+      const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', { license: warpPlus });
       if (msg?.success && msg.obj) {
         setWarpData(JSON.parse(msg.obj));
         setWarpConfig(null);
@@ -180,7 +180,7 @@ export default function WarpModal({
   async function delConfig() {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post('/panel/xray/warp/del');
+      const msg = await HttpUtil.post('/panel/api/xray/warp/del');
       if (msg?.success) {
         setWarpData(null);
         setWarpConfig(null);

+ 1 - 0
frontend/src/schemas/client.ts

@@ -43,6 +43,7 @@ export const InboundOptionSchema = z.object({
   protocol: z.string().optional(),
   port: z.number().optional(),
   tlsFlowCapable: z.boolean().optional(),
+  ssMethod: z.string().optional(),
 }).loose();
 
 export const InboundOptionsSchema = z.array(InboundOptionSchema);

+ 30 - 0
frontend/src/test/generated-examples.test.ts

@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest';
+import type { ZodType } from 'zod';
+
+import { EXAMPLES } from '@/generated/examples';
+import * as zodSchemas from '@/generated/zod';
+
+const registry = zodSchemas as unknown as Record<string, ZodType>;
+const names = Object.keys(EXAMPLES);
+
+describe('generated response examples', () => {
+  it('has at least one example to validate', () => {
+    expect(names.length).toBeGreaterThan(0);
+  });
+
+  it('pairs every example with a generated zod schema', () => {
+    const missing = names.filter((name) => typeof registry[`${name}Schema`]?.safeParse !== 'function');
+    expect(missing).toEqual([]);
+  });
+
+  it.each(names)('EXAMPLES.%s satisfies its generated zod schema', (name) => {
+    const schema = registry[`${name}Schema`];
+    const result = schema.safeParse(EXAMPLES[name]);
+    if (!result.success) {
+      throw new Error(
+        `EXAMPLES.${name} does not match ${name}Schema:\n${JSON.stringify(result.error.issues, null, 2)}`,
+      );
+    }
+    expect(result.success).toBe(true);
+  });
+});

+ 11 - 4
frontend/src/utils/index.ts

@@ -183,15 +183,22 @@ export class RandomUtil {
   }
 
   static randomShadowsocksPassword(method: string = '2022-blake3-aes-256-gcm'): string {
-    let length = 32;
-    if (method === '2022-blake3-aes-128-gcm') {
-      length = 16;
-    }
+    const length = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
     const array = new Uint8Array(length);
     window.crypto.getRandomValues(array);
     return Base64.alternativeEncode(String.fromCharCode(...array));
   }
 
+  static isShadowsocks2022Password(password: string, method: string): boolean {
+    if (!method || method.substring(0, 4) !== '2022') return true;
+    const expected = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
+    try {
+      return window.atob(password).length === expected;
+    } catch {
+      return false;
+    }
+  }
+
   static randomBase64(length: number = 16): string {
     const array = new Uint8Array(length);
     window.crypto.getRandomValues(array);

+ 1 - 1
frontend/vite.config.js

@@ -22,7 +22,7 @@ function resolveDBPath() {
   return '/etc/x-ui/x-ui.db';
 }
 
-const PANEL_API_PREFIXES = ['panel/api/', 'panel/setting/', 'panel/xray/', 'panel/csrf-token'];
+const PANEL_API_PREFIXES = ['panel/api/', 'panel/csrf-token'];
 
 let cachedBasePath = '/';
 

+ 171 - 0
tools/openapigen/emit_examples.go

@@ -0,0 +1,171 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+)
+
+func emitExamples(w io.Writer, schemas []Schema, aliases []Alias) error {
+	byName := make(map[string]Schema, len(schemas))
+	for _, s := range schemas {
+		byName[s.Name] = s
+	}
+	aliasByName := make(map[string]Alias, len(aliases))
+	for _, a := range aliases {
+		aliasByName[a.Name] = a
+	}
+
+	gen := &exampleGen{byName: byName, aliasByName: aliasByName}
+
+	out := make(map[string]any, len(schemas))
+	for _, s := range schemas {
+		out[s.Name] = gen.forSchema(s, map[string]bool{})
+	}
+
+	payload, err := json.MarshalIndent(out, "", "  ")
+	if err != nil {
+		return err
+	}
+	if _, err := fmt.Fprintln(w, examplesHeader); err != nil {
+		return err
+	}
+	if _, err := fmt.Fprintf(w, "export const EXAMPLES: Record<string, unknown> = %s;\n", payload); err != nil {
+		return err
+	}
+	return nil
+}
+
+type exampleGen struct {
+	byName      map[string]Schema
+	aliasByName map[string]Alias
+}
+
+func (g *exampleGen) forSchema(s Schema, visited map[string]bool) map[string]any {
+	obj := make(map[string]any, len(s.Fields))
+	for _, f := range s.Fields {
+		obj[f.JSONName] = g.forField(f, visited)
+	}
+	return obj
+}
+
+func (g *exampleGen) forField(f Field, visited map[string]bool) any {
+	if f.Example != "" {
+		return coerceExample(f.Example, baseKind(f.Type))
+	}
+	if v, ok := firstOneOf(f.Validate); ok {
+		return v
+	}
+	bk := baseKind(f.Type)
+	if bk.Kind == KindInt || bk.Kind == KindNumber {
+		if v, ok := numericFloor(bk.Kind, f.Validate); ok {
+			return v
+		}
+	}
+	return g.forType(f.Type, visited)
+}
+
+func (g *exampleGen) forType(t TypeRef, visited map[string]bool) any {
+	switch t.Kind {
+	case KindString:
+		if t.Name == "datetime" {
+			return "2025-01-01T00:00:00Z"
+		}
+		return ""
+	case KindInt, KindNumber:
+		return 0
+	case KindBool:
+		return false
+	case KindArray:
+		if isVisitedRef(*t.Element, visited) {
+			return []any{}
+		}
+		return []any{g.forType(*t.Element, visited)}
+	case KindMap:
+		return map[string]any{}
+	case KindRef:
+		if t.Name == "nullable" {
+			return nil
+		}
+		if alias, ok := g.aliasByName[t.Name]; ok {
+			return g.forType(alias.Underlying, visited)
+		}
+		schema, ok := g.byName[t.Name]
+		if !ok || visited[t.Name] {
+			return map[string]any{}
+		}
+		next := cloneVisited(visited)
+		next[t.Name] = true
+		return g.forSchema(schema, next)
+	}
+	return nil
+}
+
+func baseKind(t TypeRef) TypeRef {
+	if t.Kind == KindRef && t.Name == "nullable" && t.Inner != nil {
+		return *t.Inner
+	}
+	return t
+}
+
+func isVisitedRef(t TypeRef, visited map[string]bool) bool {
+	return t.Kind == KindRef && t.Name != "nullable" && visited[t.Name]
+}
+
+func cloneVisited(in map[string]bool) map[string]bool {
+	out := make(map[string]bool, len(in)+1)
+	for k, v := range in {
+		out[k] = v
+	}
+	return out
+}
+
+func numericFloor(kind TypeKind, rules []ValidateRule) (any, bool) {
+	for _, r := range rules {
+		if (r.Name == "gte" || r.Name == "min") && r.Param != "" {
+			return coerceExample(r.Param, TypeRef{Kind: kind}), true
+		}
+	}
+	return nil, false
+}
+
+func firstOneOf(rules []ValidateRule) (string, bool) {
+	for _, r := range rules {
+		if r.Name == "oneof" {
+			fields := strings.Fields(r.Param)
+			if len(fields) > 0 {
+				return fields[0], true
+			}
+		}
+	}
+	return "", false
+}
+
+func coerceExample(ex string, t TypeRef) any {
+	switch t.Kind {
+	case KindInt:
+		if n, err := strconv.ParseInt(ex, 10, 64); err == nil {
+			return n
+		}
+		return 0
+	case KindNumber:
+		if n, err := strconv.ParseFloat(ex, 64); err == nil {
+			return n
+		}
+		return 0
+	case KindBool:
+		return ex == "true"
+	case KindString:
+		return ex
+	default:
+		var parsed any
+		if err := json.Unmarshal([]byte(ex), &parsed); err == nil {
+			return parsed
+		}
+		return ex
+	}
+}
+
+const examplesHeader = `// Code generated by tools/openapigen. DO NOT EDIT.`

+ 190 - 0
tools/openapigen/emit_jsonschema.go

@@ -0,0 +1,190 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+func emitJSONSchema(w io.Writer, schemas []Schema, aliases []Alias) error {
+	byName := make(map[string]Schema, len(schemas))
+	for _, s := range schemas {
+		byName[s.Name] = s
+	}
+	aliasByName := make(map[string]Alias, len(aliases))
+	for _, a := range aliases {
+		aliasByName[a.Name] = a
+	}
+
+	gen := &schemaGen{byName: byName, aliasByName: aliasByName}
+
+	out := make(map[string]any, len(schemas))
+	for _, s := range schemas {
+		out[s.Name] = gen.objectSchema(s)
+	}
+
+	payload, err := json.MarshalIndent(out, "", "  ")
+	if err != nil {
+		return err
+	}
+	if _, err := fmt.Fprintln(w, examplesHeader); err != nil {
+		return err
+	}
+	if _, err := fmt.Fprintf(w, "export const SCHEMAS: Record<string, unknown> = %s;\n", payload); err != nil {
+		return err
+	}
+	return nil
+}
+
+type schemaGen struct {
+	byName      map[string]Schema
+	aliasByName map[string]Alias
+}
+
+func (g *schemaGen) objectSchema(s Schema) map[string]any {
+	props := make(map[string]any, len(s.Fields))
+	var required []string
+	for _, f := range s.Fields {
+		props[f.JSONName] = g.fieldSchema(f)
+		if !f.Optional {
+			required = append(required, f.JSONName)
+		}
+	}
+	obj := map[string]any{"type": "object", "properties": props}
+	if len(required) > 0 {
+		sort.Strings(required)
+		obj["required"] = required
+	}
+	if s.Doc != "" {
+		obj["description"] = s.Doc
+	}
+	return obj
+}
+
+func (g *schemaGen) fieldSchema(f Field) map[string]any {
+	sch := g.typeSchema(f.Type)
+	if ref, ok := sch["$ref"]; ok {
+		if f.Doc == "" && f.Example == "" {
+			return sch
+		}
+		wrap := map[string]any{"allOf": []any{map[string]any{"$ref": ref}}}
+		if f.Doc != "" {
+			wrap["description"] = f.Doc
+		}
+		if f.Example != "" {
+			wrap["example"] = coerceExample(f.Example, baseKind(f.Type))
+		}
+		return wrap
+	}
+	applyConstraints(sch, f.Type, f.Validate)
+	if f.Doc != "" {
+		sch["description"] = f.Doc
+	}
+	if f.Example != "" {
+		sch["example"] = coerceExample(f.Example, baseKind(f.Type))
+	}
+	return sch
+}
+
+func (g *schemaGen) typeSchema(t TypeRef) map[string]any {
+	switch t.Kind {
+	case KindString:
+		if t.Name == "datetime" {
+			return map[string]any{"type": "string", "format": "date-time"}
+		}
+		return map[string]any{"type": "string"}
+	case KindInt:
+		return map[string]any{"type": "integer"}
+	case KindNumber:
+		return map[string]any{"type": "number"}
+	case KindBool:
+		return map[string]any{"type": "boolean"}
+	case KindArray:
+		return map[string]any{"type": "array", "items": g.typeSchema(*t.Element)}
+	case KindMap:
+		return map[string]any{"type": "object", "additionalProperties": g.typeSchema(*t.Value)}
+	case KindAny, KindUnknown, KindRaw:
+		return map[string]any{}
+	case KindRef:
+		if t.Name == "nullable" {
+			inner := g.typeSchema(*t.Inner)
+			if ref, ok := inner["$ref"]; ok {
+				return map[string]any{"nullable": true, "allOf": []any{map[string]any{"$ref": ref}}}
+			}
+			inner["nullable"] = true
+			return inner
+		}
+		if alias, ok := g.aliasByName[t.Name]; ok {
+			return g.typeSchema(alias.Underlying)
+		}
+		if _, ok := g.byName[t.Name]; ok {
+			return map[string]any{"$ref": "#/components/schemas/" + t.Name}
+		}
+		return map[string]any{}
+	}
+	return map[string]any{}
+}
+
+func applyConstraints(sch map[string]any, t TypeRef, rules []ValidateRule) {
+	base := baseKind(t)
+	numeric := base.Kind == KindInt || base.Kind == KindNumber
+	str := base.Kind == KindString
+	for _, r := range rules {
+		switch r.Name {
+		case "gte":
+			if numeric {
+				sch["minimum"] = coerceExample(r.Param, base)
+			}
+		case "lte":
+			if numeric {
+				sch["maximum"] = coerceExample(r.Param, base)
+			}
+		case "gt":
+			if numeric {
+				sch["minimum"] = coerceExample(r.Param, base)
+				sch["exclusiveMinimum"] = true
+			}
+		case "lt":
+			if numeric {
+				sch["maximum"] = coerceExample(r.Param, base)
+				sch["exclusiveMaximum"] = true
+			}
+		case "min":
+			if numeric {
+				sch["minimum"] = coerceExample(r.Param, base)
+			} else if str {
+				if n, err := strconv.Atoi(r.Param); err == nil {
+					sch["minLength"] = n
+				}
+			}
+		case "max":
+			if numeric {
+				sch["maximum"] = coerceExample(r.Param, base)
+			} else if str {
+				if n, err := strconv.Atoi(r.Param); err == nil {
+					sch["maxLength"] = n
+				}
+			}
+		case "oneof":
+			vals := strings.Fields(r.Param)
+			if len(vals) > 0 {
+				enum := make([]any, len(vals))
+				for i, v := range vals {
+					enum[i] = v
+				}
+				sch["enum"] = enum
+			}
+		case "email":
+			if str {
+				sch["format"] = "email"
+			}
+		case "url":
+			if str {
+				sch["format"] = "uri"
+			}
+		}
+	}
+}

+ 22 - 0
tools/openapigen/main.go

@@ -69,6 +69,14 @@ func run(root, outDir string) error {
 				"ClientTraffic",
 			),
 		},
+		{
+			Path: resolveRel(root, "web/service"),
+			StructAllow: setOf(
+				"InboundOption",
+				"ApiTokenView",
+				"ProbeResultUI",
+			),
+		},
 	}
 
 	schemas, aliases, err := walkPackages(requests)
@@ -94,6 +102,14 @@ func run(root, outDir string) error {
 	if err := emitTypes(typesBuf, schemas, aliases); err != nil {
 		return err
 	}
+	examplesBuf := &bytes.Buffer{}
+	if err := emitExamples(examplesBuf, schemas, aliases); err != nil {
+		return err
+	}
+	schemasBuf := &bytes.Buffer{}
+	if err := emitJSONSchema(schemasBuf, schemas, aliases); err != nil {
+		return err
+	}
 
 	if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil {
 		return err
@@ -101,6 +117,12 @@ func run(root, outDir string) error {
 	if err := os.WriteFile(filepath.Join(target, "types.ts"), typesBuf.Bytes(), 0o644); err != nil {
 		return err
 	}
+	if err := os.WriteFile(filepath.Join(target, "examples.ts"), examplesBuf.Bytes(), 0o644); err != nil {
+		return err
+	}
+	if err := os.WriteFile(filepath.Join(target, "schemas.ts"), schemasBuf.Bytes(), 0o644); err != nil {
+		return err
+	}
 
 	fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target)
 	return nil

+ 3 - 1
tools/openapigen/schema.go

@@ -27,6 +27,7 @@ type Field struct {
 	Skip     bool
 	Validate []ValidateRule
 	Doc      string
+	Example  string
 }
 
 type TypeRef struct {
@@ -59,10 +60,11 @@ type ValidateRule struct {
 	Param string
 }
 
-func parseStructTag(raw string) (json string, validate string, gormHasDash bool) {
+func parseStructTag(raw string) (json string, validate string, example string, gormHasDash bool) {
 	tag := reflect.StructTag(strings.Trim(raw, "`"))
 	json = tag.Get("json")
 	validate = tag.Get("validate")
+	example = tag.Get("example")
 	if g := tag.Get("gorm"); g != "" {
 		for part := range strings.SplitSeq(g, ";") {
 			if strings.TrimSpace(part) == "-" {

+ 3 - 1
tools/openapigen/walker.go

@@ -102,7 +102,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
 	if fld.Tag != nil {
 		tag = fld.Tag.Value
 	}
-	jsonTag, validateTag, gormDash := parseStructTag(tag)
+	jsonTag, validateTag, exampleTag, gormDash := parseStructTag(tag)
 	if gormDash && jsonTag == "" {
 		return nil
 	}
@@ -132,6 +132,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
 			Optional: omitempty || isPointer(fld.Type),
 			Validate: validate,
 			Doc:      doc,
+			Example:  exampleTag,
 		})
 	}
 
@@ -154,6 +155,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
 			Optional: omitempty || isPointer(fld.Type),
 			Validate: validate,
 			Doc:      doc,
+			Example:  exampleTag,
 		})
 	}
 

+ 15 - 7
web/controller/api.go

@@ -14,13 +14,15 @@ import (
 // APIController handles the main API routes for the 3x-ui panel, including inbounds and server management.
 type APIController struct {
 	BaseController
-	inboundController *InboundController
-	serverController  *ServerController
-	nodeController    *NodeController
-	settingService    service.SettingService
-	userService       service.UserService
-	apiTokenService   service.ApiTokenService
-	Tgbot             service.Tgbot
+	inboundController     *InboundController
+	serverController      *ServerController
+	nodeController        *NodeController
+	settingController     *SettingController
+	xraySettingController *XraySettingController
+	settingService        service.SettingService
+	userService           service.UserService
+	apiTokenService       service.ApiTokenService
+	Tgbot                 service.Tgbot
 }
 
 // NewAPIController creates a new APIController instance and initializes its routes.
@@ -79,6 +81,12 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
 
 	NewCustomGeoController(api.Group("/custom-geo"), customGeo)
 
+	// Settings + Xray config management live under the API surface too, so the
+	// same API token drives them. Paths are /panel/api/setting/* and
+	// /panel/api/xray/*.
+	a.settingController = NewSettingController(api)
+	a.xraySettingController = NewXraySettingController(api)
+
 	// Extra routes
 	api.POST("/backuptotgbot", a.BackuptoTgbot)
 }

+ 2 - 2
web/controller/api_docs_test.go

@@ -96,9 +96,9 @@ func TestAPIRoutesDocumented(t *testing.T) {
 		case "node.go":
 			basePath = "/panel/api/nodes"
 		case "setting.go":
-			basePath = "/panel/setting"
+			basePath = "/panel/api/setting"
 		case "xray_setting.go":
-			basePath = "/panel/xray"
+			basePath = "/panel/api/xray"
 		case "custom_geo.go":
 			basePath = "/panel/api/custom-geo"
 		case "websocket.go":

+ 33 - 0
web/controller/dist.go

@@ -3,6 +3,7 @@ package controller
 import (
 	"bytes"
 	"embed"
+	"encoding/json"
 	htmlpkg "html"
 	"net/http"
 	"strings"
@@ -34,10 +35,42 @@ func ServeOpenAPISpec(c *gin.Context) {
 		c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
 		return
 	}
+
+	// The embedded spec ships with `servers: [{url: "/"}]`. When the panel runs
+	// under a non-root web base path, Swagger UI "Try it out" and external
+	// generators must target that prefix, so rewrite the single server entry to
+	// the runtime base path before serving.
+	if basePath := c.GetString("base_path"); basePath != "" && basePath != "/" {
+		if rebuilt, err := withServerBasePath(body, basePath); err != nil {
+			logger.Warning("openapi.json: could not inject base path:", err)
+		} else {
+			body = rebuilt
+		}
+	}
+
 	c.Header("Cache-Control", "public, max-age=300")
 	c.Data(http.StatusOK, "application/json; charset=utf-8", body)
 }
 
+// withServerBasePath rewrites the spec's `servers` entry so requests target the
+// panel's configured web base path. Only the top-level `servers` field is
+// replaced; every other field is preserved verbatim via json.RawMessage.
+func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
+	var doc map[string]json.RawMessage
+	if err := json.Unmarshal(spec, &doc); err != nil {
+		return nil, err
+	}
+	servers, err := json.Marshal([]map[string]string{{
+		"url":         strings.TrimSuffix(basePath, "/"),
+		"description": "Current panel",
+	}})
+	if err != nil {
+		return nil, err
+	}
+	doc["servers"] = servers
+	return json.Marshal(doc)
+}
+
 func serveDistPage(c *gin.Context, name string) {
 	body, err := distFS.ReadFile("dist/" + name)
 	if err != nil {

+ 42 - 0
web/controller/dist_test.go

@@ -0,0 +1,42 @@
+package controller
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+func TestWithServerBasePath(t *testing.T) {
+	spec := []byte(`{"openapi":"3.0.3","info":{"title":"x"},"servers":[{"url":"/","description":"old"}],"paths":{"/p":{"get":{"summary":"s"}}}}`)
+
+	out, err := withServerBasePath(spec, "/test/")
+	if err != nil {
+		t.Fatalf("withServerBasePath: %v", err)
+	}
+
+	var doc map[string]any
+	if err := json.Unmarshal(out, &doc); err != nil {
+		t.Fatalf("unmarshal result: %v", err)
+	}
+
+	servers, ok := doc["servers"].([]any)
+	if !ok || len(servers) != 1 {
+		t.Fatalf("servers = %v, want one entry", doc["servers"])
+	}
+	srv, _ := servers[0].(map[string]any)
+	if srv["url"] != "/test" {
+		t.Errorf("server url = %v, want /test (trailing slash trimmed)", srv["url"])
+	}
+
+	if doc["openapi"] != "3.0.3" {
+		t.Errorf("openapi field not preserved: %v", doc["openapi"])
+	}
+	if _, ok := doc["paths"].(map[string]any)["/p"]; !ok {
+		t.Errorf("paths content not preserved verbatim")
+	}
+}
+
+func TestWithServerBasePathInvalidJSON(t *testing.T) {
+	if _, err := withServerBasePath([]byte("not json"), "/test/"); err == nil {
+		t.Errorf("expected error on invalid spec, got nil")
+	}
+}

+ 1 - 7
web/controller/xui.go

@@ -10,12 +10,9 @@ import (
 	"github.com/gin-gonic/gin"
 )
 
-// XUIController is the main controller for the X-UI panel, managing sub-controllers.
+// XUIController is the main controller for the X-UI panel, serving the SPA shell.
 type XUIController struct {
 	BaseController
-
-	settingController     *SettingController
-	xraySettingController *XraySettingController
 }
 
 // NewXUIController creates a new XUIController and initializes its routes.
@@ -49,9 +46,6 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
 	// so they fetch the session token via this endpoint at startup and replay it
 	// on subsequent unsafe requests through axios.
 	g.GET("/csrf-token", a.csrfToken)
-
-	a.settingController = NewSettingController(g)
-	a.xraySettingController = NewXraySettingController(g)
 }
 
 // panelSPA serves the React SPA shell. Every GET under /panel/ that isn't an

+ 5 - 5
web/service/api_token.go

@@ -17,11 +17,11 @@ type ApiTokenService struct{}
 const apiTokenLength = 48
 
 type ApiTokenView struct {
-	Id        int    `json:"id"`
-	Name      string `json:"name"`
-	Token     string `json:"token,omitempty"`
-	Enabled   bool   `json:"enabled"`
-	CreatedAt int64  `json:"createdAt"`
+	Id        int    `json:"id" example:"2"`
+	Name      string `json:"name" example:"central-panel-a"`
+	Token     string `json:"token,omitempty" example:"new-token-string"`
+	Enabled   bool   `json:"enabled" example:"true"`
+	CreatedAt int64  `json:"createdAt" example:"1736000000"`
 }
 
 // toView builds the metadata view returned by List. It never carries the

+ 26 - 7
web/service/inbound.go

@@ -356,12 +356,13 @@ func (s *InboundService) annotateFallbackParents(db *gorm.DB, inbounds []*model.
 }
 
 type InboundOption struct {
-	Id             int    `json:"id"`
-	Remark         string `json:"remark"`
-	Tag            string `json:"tag"`
-	Protocol       string `json:"protocol"`
-	Port           int    `json:"port"`
-	TlsFlowCapable bool   `json:"tlsFlowCapable"`
+	Id             int    `json:"id" example:"1"`
+	Remark         string `json:"remark" example:"VLESS-443"`
+	Tag            string `json:"tag" example:"in-443-tcp"`
+	Protocol       string `json:"protocol" example:"vless"`
+	Port           int    `json:"port" example:"443"`
+	TlsFlowCapable bool   `json:"tlsFlowCapable" example:"true"`
+	SsMethod       string `json:"ssMethod"`
 }
 
 func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) {
@@ -373,9 +374,10 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 		Protocol       string `gorm:"column:protocol"`
 		Port           int    `gorm:"column:port"`
 		StreamSettings string `gorm:"column:stream_settings"`
+		Settings       string `gorm:"column:settings"`
 	}
 	err := db.Table("inbounds").
-		Select("id, remark, tag, protocol, port, stream_settings").
+		Select("id, remark, tag, protocol, port, stream_settings, settings").
 		Where("user_id = ?", userId).
 		Order("id ASC").
 		Scan(&rows).Error
@@ -391,11 +393,28 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 			Protocol:       r.Protocol,
 			Port:           r.Port,
 			TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings),
+			SsMethod:       inboundShadowsocksMethod(r.Protocol, r.Settings),
 		})
 	}
 	return out, nil
 }
 
+// inboundShadowsocksMethod extracts settings.method for Shadowsocks inbounds so
+// the client UI can generate a valid PSK (base64 of the method's key length)
+// for Shadowsocks 2022 ciphers. Returns "" for non-Shadowsocks inbounds.
+func inboundShadowsocksMethod(protocol, settings string) string {
+	if protocol != string(model.Shadowsocks) || settings == "" {
+		return ""
+	}
+	var s struct {
+		Method string `json:"method"`
+	}
+	if err := json.Unmarshal([]byte(settings), &s); err != nil {
+		return ""
+	}
+	return s.Method
+}
+
 // inboundCanEnableTlsFlow mirrors Inbound.canEnableTlsFlow() from the frontend:
 // XTLS Vision is only valid for VLESS on TCP with tls or reality.
 func inboundCanEnableTlsFlow(protocol, streamSettings string) bool {

+ 7 - 7
web/service/node.go

@@ -635,13 +635,13 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 }
 
 type ProbeResultUI struct {
-	Status       string  `json:"status"`
-	LatencyMs    int     `json:"latencyMs"`
-	XrayVersion  string  `json:"xrayVersion"`
-	PanelVersion string  `json:"panelVersion"`
-	CpuPct       float64 `json:"cpuPct"`
-	MemPct       float64 `json:"memPct"`
-	UptimeSecs   uint64  `json:"uptimeSecs"`
+	Status       string  `json:"status" example:"online"`
+	LatencyMs    int     `json:"latencyMs" example:"42"`
+	XrayVersion  string  `json:"xrayVersion" example:"25.10.31"`
+	PanelVersion string  `json:"panelVersion" example:"v3.x.x"`
+	CpuPct       float64 `json:"cpuPct" example:"12.5"`
+	MemPct       float64 `json:"memPct" example:"45.2"`
+	UptimeSecs   uint64  `json:"uptimeSecs" example:"86400"`
 	Error        string  `json:"error"`
 }
 

+ 12 - 12
xray/client_traffic.go

@@ -3,16 +3,16 @@ package xray
 // ClientTraffic represents traffic statistics and limits for a specific client.
 // It tracks upload/download usage, expiry times, and online status for inbound clients.
 type ClientTraffic struct {
-	Id         int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
-	InboundId  int    `json:"inboundId" form:"inboundId"`
-	Enable     bool   `json:"enable" form:"enable"`
-	Email      string `json:"email" form:"email" gorm:"unique"`
-	UUID       string `json:"uuid" form:"uuid" gorm:"-"`
-	SubId      string `json:"subId" form:"subId" gorm:"-"`
-	Up         int64  `json:"up" form:"up"`
-	Down       int64  `json:"down" form:"down"`
-	ExpiryTime int64  `json:"expiryTime" form:"expiryTime"`
-	Total      int64  `json:"total" form:"total"`
-	Reset      int    `json:"reset" form:"reset" gorm:"default:0"`
-	LastOnline int64  `json:"lastOnline" form:"lastOnline" gorm:"default:0"`
+	Id         int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"`
+	InboundId  int    `json:"inboundId" form:"inboundId" example:"1"`
+	Enable     bool   `json:"enable" form:"enable" example:"true"`
+	Email      string `json:"email" form:"email" gorm:"unique" example:"user1"`
+	UUID       string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"`
+	SubId      string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"`
+	Up         int64  `json:"up" form:"up" example:"1048576"`
+	Down       int64  `json:"down" form:"down" example:"2097152"`
+	ExpiryTime int64  `json:"expiryTime" form:"expiryTime" example:"1735689600000"`
+	Total      int64  `json:"total" form:"total" example:"10737418240"`
+	Reset      int    `json:"reset" form:"reset" gorm:"default:0" example:"0"`
+	LastOnline int64  `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"`
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов