P9(4): remove local-core live-test trio — outbound speed test + egress probe, restart/stop xray endpoints

Per user decision 2026-10-21 (reverses the earlier keep call in
PANEL-LITE-SPEC.md, now amended): the lite panel never runs a local
core, so measuring/probing from the panel host is meaningless.

Backend:
- delete service/outbound probe implementation (probe_http.go and its
  tests) and the TestOutbound/TestOutbounds batch prober; outbound
  traffic accounting (AddTraffic/GetOutboundsTraffic/Reset) unchanged
- drop POST /panel/api/xray/testOutbound + testOutbounds routes and the
  outboundTestUrl setting (get/update plumbing removed; stored rows in
  existing DBs are inert)
- drop POST /panel/api/server/restartXrayService + stopXrayService
  routes/handlers and the node-sync allowlist entry; the service-level
  Stop/RestartXrayService stay for internal import/restore flows
- node-sync allowlist contract test updated to the new inventory

Frontend:
- remove test buttons/mode switch/test-all from outbounds tab, card
  list and table columns; delete TestResultPopover and CountryPill;
  drop the egress/country/latency columns fed by probe results
- remove outboundTestUrl state and save plumbing from useXraySetting
  and the Basics tab field; drop OutboundTestResult schemas
- delete the 15 now-dead i18n keys from all 13 locales (incl. the
  restart/stop keys orphaned since P6/P7 — i18n-dead-keys test green)
- tests: drop the test-URL hook test, re-probe the loopback index
  mapping through Move-to-top, regenerate API types (gen)
This commit is contained in:
Muse
2026-10-21 16:35:53 +00:00
parent 6772c97f8e
commit 5031a70ccb
43 changed files with 112 additions and 3982 deletions
+5 -149
View File
@@ -3,64 +3,24 @@ export const EXAMPLES: Record<string, unknown> = {
"AllSetting": {
"datepicker": "",
"expireDiff": 0,
"externalTrafficInformEnable": false,
"externalTrafficInformURI": "",
"ipLimitAllowlist": "",
"ldapAutoCreate": false,
"ldapAutoDelete": false,
"ldapBaseDN": "",
"ldapBindDN": "",
"ldapDefaultExpiryDays": 0,
"ldapDefaultLimitIP": 0,
"ldapDefaultTotalGB": 0,
"ldapEnable": false,
"ldapFlagField": "",
"ldapHost": "",
"ldapInboundTags": "",
"ldapInsecureSkipVerify": false,
"ldapInvertFlag": false,
"ldapPassword": "",
"ldapPort": 0,
"ldapSyncCron": "",
"ldapTruthyValues": "",
"ldapUseTLS": false,
"ldapUserAttr": "",
"ldapUserFilter": "",
"ldapVlessField": "",
"outboundDownThreshold": 1,
"pageSize": 0,
"panelOutbound": "",
"remarkTemplate": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"smtpCpu": 0,
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpHost": "",
"smtpMemory": 0,
"smtpPassword": "",
"smtpPort": 1,
"smtpTo": "",
"smtpUsername": "",
"subAnnounce": "",
"subCertFile": "",
"subClashAutoDetect": false,
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRules": "",
"subClashURI": "",
"subClashUserAgentRegex": "",
"subDomain": "",
"subEnable": false,
"subEnableRouting": false,
"subEncrypt": false,
"subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonAlwaysArray": false,
"subJsonAutoDetect": false,
"subJsonEnable": false,
@@ -68,37 +28,22 @@ export const EXAMPLES: Record<string, unknown> = {
"subJsonMux": "",
"subJsonObservatory": "",
"subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "",
"subJsonUserAgentRegex": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
"subPort": 1,
"subPort": 0,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowIdentityOnAllLinks": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
"tgBotAPIServer": "",
"tgBotBackup": false,
"tgBotChatId": "",
"tgBotEnable": false,
"tgBotProxy": "",
"tgBotToken": "",
"tgCpu": 0,
"tgEnabledEvents": "",
"tgLang": "",
"tgMemory": 0,
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"warpUpdateInterval": 0,
"webBasePath": "",
"webCertFile": "",
@@ -110,71 +55,27 @@ export const EXAMPLES: Record<string, unknown> = {
"AllSettingView": {
"datepicker": "",
"expireDiff": 0,
"externalTrafficInformEnable": false,
"externalTrafficInformURI": "",
"hasApiToken": false,
"hasLdapPassword": false,
"hasNordSecret": false,
"hasSmtpPassword": false,
"hasTgBotToken": false,
"hasTwoFactorToken": false,
"hasWarpSecret": false,
"ipLimitAllowlist": "",
"ldapAutoCreate": false,
"ldapAutoDelete": false,
"ldapBaseDN": "",
"ldapBindDN": "",
"ldapDefaultExpiryDays": 0,
"ldapDefaultLimitIP": 0,
"ldapDefaultTotalGB": 0,
"ldapEnable": false,
"ldapFlagField": "",
"ldapHost": "",
"ldapInboundTags": "",
"ldapInsecureSkipVerify": false,
"ldapInvertFlag": false,
"ldapPassword": "",
"ldapPort": 0,
"ldapSyncCron": "",
"ldapTruthyValues": "",
"ldapUseTLS": false,
"ldapUserAttr": "",
"ldapUserFilter": "",
"ldapVlessField": "",
"outboundDownThreshold": 1,
"pageSize": 0,
"panelOutbound": "",
"remarkTemplate": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"smtpCpu": 0,
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpHost": "",
"smtpMemory": 0,
"smtpPassword": "",
"smtpPort": 1,
"smtpTo": "",
"smtpUsername": "",
"subAnnounce": "",
"subCertFile": "",
"subClashAutoDetect": false,
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRules": "",
"subClashURI": "",
"subClashUserAgentRegex": "",
"subDomain": "",
"subEnable": false,
"subEnableRouting": false,
"subEncrypt": false,
"subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonAlwaysArray": false,
"subJsonAutoDetect": false,
"subJsonEnable": false,
@@ -182,37 +83,22 @@ export const EXAMPLES: Record<string, unknown> = {
"subJsonMux": "",
"subJsonObservatory": "",
"subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "",
"subJsonUserAgentRegex": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
"subPort": 1,
"subPort": 0,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowIdentityOnAllLinks": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
"tgBotAPIServer": "",
"tgBotBackup": false,
"tgBotChatId": "",
"tgBotEnable": false,
"tgBotProxy": "",
"tgBotToken": "",
"tgCpu": 0,
"tgEnabledEvents": "",
"tgLang": "",
"tgMemory": 0,
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"warpUpdateInterval": 0,
"webBasePath": "",
"webCertFile": "",
@@ -221,26 +107,6 @@ export const EXAMPLES: Record<string, unknown> = {
"webListen": "",
"webPort": 1
},
"AmneziaWGLogs": {
"events": [
"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
],
"peers": [
{
"allowedIPs": "10.8.1.2/32",
"down": 4194304,
"email": "peer@example.com",
"endpoint": "203.0.113.9:51820",
"handshake": 1735732800000,
"inboundId": 1,
"interface": "awg1",
"online": true,
"tag": "inbound-51820",
"up": 1048576
}
],
"running": true
},
"ApiToken": {
"createdAt": 0,
"enabled": false,
@@ -648,9 +514,10 @@ export const EXAMPLES: Record<string, unknown> = {
""
],
"name": "",
"nodeType": "traditional",
"outboundTag": "",
"pinnedCertSha256": "",
"port": 1,
"port": 0,
"remark": "",
"scheme": "http",
"tlsVerifyMode": "verify"
@@ -683,6 +550,7 @@ export const EXAMPLES: Record<string, unknown> = {
"name": "edge-1",
"netDown": 1048576,
"netUp": 2097152,
"nodeType": "traditional",
"onlineCount": 5,
"outboundTag": "direct",
"panelVersion": "v3.x.x",
@@ -713,18 +581,6 @@ export const EXAMPLES: Record<string, unknown> = {
"runId": "1735689600123456789",
"state": "success"
},
"PeerActivity": {
"allowedIPs": "10.8.1.2/32",
"down": 4194304,
"email": "peer@example.com",
"endpoint": "203.0.113.9:51820",
"handshake": 1735732800000,
"inboundId": 1,
"interface": "awg1",
"online": true,
"tag": "inbound-51820",
"up": 1048576
},
"ProbeResultUI": {
"cpuPct": 12.5,
"error": "",
+18 -581
View File
@@ -9,83 +9,9 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"externalTrafficInformEnable": {
"type": "boolean"
},
"externalTrafficInformURI": {
"type": "string"
},
"ipLimitAllowlist": {
"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": {
"type": "boolean"
},
"ldapFlagField": {
"type": "string"
},
"ldapHost": {
"type": "string"
},
"ldapInboundTags": {
"type": "string"
},
"ldapInsecureSkipVerify": {
"type": "boolean"
},
"ldapInvertFlag": {
"type": "boolean"
},
"ldapPassword": {
"type": "string"
},
"ldapPort": {
"maximum": 65535,
"minimum": 0,
"type": "integer"
},
"ldapSyncCron": {
"type": "string"
},
"ldapTruthyValues": {
"type": "string"
},
"ldapUseTLS": {
"type": "boolean"
},
"ldapUserAttr": {
"type": "string"
},
"ldapUserFilter": {
"type": "string"
},
"ldapVlessField": {
"type": "string"
},
"outboundDownThreshold": {
"maximum": 100,
"minimum": 1,
@@ -110,48 +36,6 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 1,
"type": "integer"
},
"smtpCpu": {
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"smtpEnable": {
"type": "boolean"
},
"smtpEnabledEvents": {
"type": "string"
},
"smtpEncryptionType": {
"type": "string"
},
"smtpFrom": {
"type": "string"
},
"smtpFromName": {
"type": "string"
},
"smtpHost": {
"type": "string"
},
"smtpMemory": {
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"smtpPassword": {
"type": "string"
},
"smtpPort": {
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"smtpTo": {
"type": "string"
},
"smtpUsername": {
"type": "string"
},
"subAnnounce": {
"type": "string"
},
@@ -164,15 +48,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subClashEnable": {
"type": "boolean"
},
"subClashEnableRouting": {
"type": "boolean"
},
"subClashPath": {
"type": "string"
},
"subClashRules": {
"type": "string"
},
"subClashURI": {
"type": "string"
},
@@ -185,21 +63,12 @@ export const SCHEMAS: Record<string, unknown> = {
"subEnable": {
"type": "boolean"
},
"subEnableRouting": {
"type": "boolean"
},
"subEncrypt": {
"type": "boolean"
},
"subHideSettings": {
"type": "boolean"
},
"subIncyEnableRouting": {
"type": "boolean"
},
"subIncyRoutingRules": {
"type": "string"
},
"subJsonAlwaysArray": {
"type": "boolean"
},
@@ -221,9 +90,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonPath": {
"type": "string"
},
"subJsonRules": {
"type": "string"
},
"subJsonURI": {
"type": "string"
},
@@ -240,16 +106,14 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "string"
},
"subPort": {
"description": "SubPort 0 means \"no explicit port\": subscription links are generated\nwithout a port (standard scheme port behind a reverse proxy) while the\nsub server still binds the default port. See SettingService.BuildSubURIBase\nand the sub server bootstrap for the two sides of that split.",
"maximum": 65535,
"minimum": 1,
"minimum": 0,
"type": "integer"
},
"subProfileUrl": {
"type": "string"
},
"subRoutingRules": {
"type": "string"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
},
@@ -270,43 +134,6 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"tgBotAPIServer": {
"type": "string"
},
"tgBotBackup": {
"type": "boolean"
},
"tgBotChatId": {
"type": "string"
},
"tgBotEnable": {
"type": "boolean"
},
"tgBotProxy": {
"type": "string"
},
"tgBotToken": {
"type": "string"
},
"tgCpu": {
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"tgEnabledEvents": {
"type": "string"
},
"tgLang": {
"type": "string"
},
"tgMemory": {
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"tgRunTime": {
"type": "string"
},
"timeLocation": {
"type": "string"
},
@@ -318,12 +145,6 @@ export const SCHEMAS: Record<string, unknown> = {
"trustedProxyCIDRs": {
"type": "string"
},
"twoFactorEnable": {
"type": "boolean"
},
"twoFactorToken": {
"type": "string"
},
"warpUpdateInterval": {
"minimum": 0,
"type": "integer"
@@ -352,64 +173,24 @@ export const SCHEMAS: Record<string, unknown> = {
"required": [
"datepicker",
"expireDiff",
"externalTrafficInformEnable",
"externalTrafficInformURI",
"ipLimitAllowlist",
"ldapAutoCreate",
"ldapAutoDelete",
"ldapBaseDN",
"ldapBindDN",
"ldapDefaultExpiryDays",
"ldapDefaultLimitIP",
"ldapDefaultTotalGB",
"ldapEnable",
"ldapFlagField",
"ldapHost",
"ldapInboundTags",
"ldapInsecureSkipVerify",
"ldapInvertFlag",
"ldapPassword",
"ldapPort",
"ldapSyncCron",
"ldapTruthyValues",
"ldapUseTLS",
"ldapUserAttr",
"ldapUserFilter",
"ldapVlessField",
"outboundDownThreshold",
"pageSize",
"panelOutbound",
"remarkTemplate",
"restartXrayOnClientDisable",
"sessionMaxAge",
"smtpCpu",
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpFrom",
"smtpFromName",
"smtpHost",
"smtpMemory",
"smtpPassword",
"smtpPort",
"smtpTo",
"smtpUsername",
"subAnnounce",
"subCertFile",
"subClashAutoDetect",
"subClashEnable",
"subClashEnableRouting",
"subClashPath",
"subClashRules",
"subClashURI",
"subClashUserAgentRegex",
"subDomain",
"subEnable",
"subEnableRouting",
"subEncrypt",
"subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonAlwaysArray",
"subJsonAutoDetect",
"subJsonEnable",
@@ -417,7 +198,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonMux",
"subJsonObservatory",
"subJsonPath",
"subJsonRules",
"subJsonURI",
"subJsonUserAgentRegex",
"subKeyFile",
@@ -425,29 +205,15 @@ export const SCHEMAS: Record<string, unknown> = {
"subPath",
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
"subURI",
"subUpdates",
"tgBotAPIServer",
"tgBotBackup",
"tgBotChatId",
"tgBotEnable",
"tgBotProxy",
"tgBotToken",
"tgCpu",
"tgEnabledEvents",
"tgLang",
"tgMemory",
"tgRunTime",
"timeLocation",
"trafficDiff",
"trustedProxyCIDRs",
"twoFactorEnable",
"twoFactorToken",
"warpUpdateInterval",
"webBasePath",
"webCertFile",
@@ -467,104 +233,18 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"externalTrafficInformEnable": {
"type": "boolean"
},
"externalTrafficInformURI": {
"type": "string"
},
"hasApiToken": {
"type": "boolean"
},
"hasLdapPassword": {
"type": "boolean"
},
"hasNordSecret": {
"type": "boolean"
},
"hasSmtpPassword": {
"type": "boolean"
},
"hasTgBotToken": {
"type": "boolean"
},
"hasTwoFactorToken": {
"type": "boolean"
},
"hasWarpSecret": {
"type": "boolean"
},
"ipLimitAllowlist": {
"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": {
"type": "boolean"
},
"ldapFlagField": {
"type": "string"
},
"ldapHost": {
"type": "string"
},
"ldapInboundTags": {
"type": "string"
},
"ldapInsecureSkipVerify": {
"type": "boolean"
},
"ldapInvertFlag": {
"type": "boolean"
},
"ldapPassword": {
"type": "string"
},
"ldapPort": {
"maximum": 65535,
"minimum": 0,
"type": "integer"
},
"ldapSyncCron": {
"type": "string"
},
"ldapTruthyValues": {
"type": "string"
},
"ldapUseTLS": {
"type": "boolean"
},
"ldapUserAttr": {
"type": "string"
},
"ldapUserFilter": {
"type": "string"
},
"ldapVlessField": {
"type": "string"
},
"outboundDownThreshold": {
"maximum": 100,
"minimum": 1,
@@ -589,48 +269,6 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 1,
"type": "integer"
},
"smtpCpu": {
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"smtpEnable": {
"type": "boolean"
},
"smtpEnabledEvents": {
"type": "string"
},
"smtpEncryptionType": {
"type": "string"
},
"smtpFrom": {
"type": "string"
},
"smtpFromName": {
"type": "string"
},
"smtpHost": {
"type": "string"
},
"smtpMemory": {
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"smtpPassword": {
"type": "string"
},
"smtpPort": {
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"smtpTo": {
"type": "string"
},
"smtpUsername": {
"type": "string"
},
"subAnnounce": {
"type": "string"
},
@@ -643,15 +281,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subClashEnable": {
"type": "boolean"
},
"subClashEnableRouting": {
"type": "boolean"
},
"subClashPath": {
"type": "string"
},
"subClashRules": {
"type": "string"
},
"subClashURI": {
"type": "string"
},
@@ -664,21 +296,12 @@ export const SCHEMAS: Record<string, unknown> = {
"subEnable": {
"type": "boolean"
},
"subEnableRouting": {
"type": "boolean"
},
"subEncrypt": {
"type": "boolean"
},
"subHideSettings": {
"type": "boolean"
},
"subIncyEnableRouting": {
"type": "boolean"
},
"subIncyRoutingRules": {
"type": "string"
},
"subJsonAlwaysArray": {
"type": "boolean"
},
@@ -700,9 +323,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonPath": {
"type": "string"
},
"subJsonRules": {
"type": "string"
},
"subJsonURI": {
"type": "string"
},
@@ -719,16 +339,14 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "string"
},
"subPort": {
"description": "SubPort 0 means \"no explicit port\": subscription links are generated\nwithout a port (standard scheme port behind a reverse proxy) while the\nsub server still binds the default port. See SettingService.BuildSubURIBase\nand the sub server bootstrap for the two sides of that split.",
"maximum": 65535,
"minimum": 1,
"minimum": 0,
"type": "integer"
},
"subProfileUrl": {
"type": "string"
},
"subRoutingRules": {
"type": "string"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
},
@@ -749,43 +367,6 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"tgBotAPIServer": {
"type": "string"
},
"tgBotBackup": {
"type": "boolean"
},
"tgBotChatId": {
"type": "string"
},
"tgBotEnable": {
"type": "boolean"
},
"tgBotProxy": {
"type": "string"
},
"tgBotToken": {
"type": "string"
},
"tgCpu": {
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"tgEnabledEvents": {
"type": "string"
},
"tgLang": {
"type": "string"
},
"tgMemory": {
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"tgRunTime": {
"type": "string"
},
"timeLocation": {
"type": "string"
},
@@ -797,12 +378,6 @@ export const SCHEMAS: Record<string, unknown> = {
"trustedProxyCIDRs": {
"type": "string"
},
"twoFactorEnable": {
"type": "boolean"
},
"twoFactorToken": {
"type": "string"
},
"warpUpdateInterval": {
"minimum": 0,
"type": "integer"
@@ -831,71 +406,27 @@ export const SCHEMAS: Record<string, unknown> = {
"required": [
"datepicker",
"expireDiff",
"externalTrafficInformEnable",
"externalTrafficInformURI",
"hasApiToken",
"hasLdapPassword",
"hasNordSecret",
"hasSmtpPassword",
"hasTgBotToken",
"hasTwoFactorToken",
"hasWarpSecret",
"ipLimitAllowlist",
"ldapAutoCreate",
"ldapAutoDelete",
"ldapBaseDN",
"ldapBindDN",
"ldapDefaultExpiryDays",
"ldapDefaultLimitIP",
"ldapDefaultTotalGB",
"ldapEnable",
"ldapFlagField",
"ldapHost",
"ldapInboundTags",
"ldapInsecureSkipVerify",
"ldapInvertFlag",
"ldapPassword",
"ldapPort",
"ldapSyncCron",
"ldapTruthyValues",
"ldapUseTLS",
"ldapUserAttr",
"ldapUserFilter",
"ldapVlessField",
"outboundDownThreshold",
"pageSize",
"panelOutbound",
"remarkTemplate",
"restartXrayOnClientDisable",
"sessionMaxAge",
"smtpCpu",
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpFrom",
"smtpFromName",
"smtpHost",
"smtpMemory",
"smtpPassword",
"smtpPort",
"smtpTo",
"smtpUsername",
"subAnnounce",
"subCertFile",
"subClashAutoDetect",
"subClashEnable",
"subClashEnableRouting",
"subClashPath",
"subClashRules",
"subClashURI",
"subClashUserAgentRegex",
"subDomain",
"subEnable",
"subEnableRouting",
"subEncrypt",
"subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonAlwaysArray",
"subJsonAutoDetect",
"subJsonEnable",
@@ -903,7 +434,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonMux",
"subJsonObservatory",
"subJsonPath",
"subJsonRules",
"subJsonURI",
"subJsonUserAgentRegex",
"subKeyFile",
@@ -911,29 +441,15 @@ export const SCHEMAS: Record<string, unknown> = {
"subPath",
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
"subURI",
"subUpdates",
"tgBotAPIServer",
"tgBotBackup",
"tgBotChatId",
"tgBotEnable",
"tgBotProxy",
"tgBotToken",
"tgCpu",
"tgEnabledEvents",
"tgLang",
"tgMemory",
"tgRunTime",
"timeLocation",
"trafficDiff",
"trustedProxyCIDRs",
"twoFactorEnable",
"twoFactorToken",
"warpUpdateInterval",
"webBasePath",
"webCertFile",
@@ -944,36 +460,6 @@ export const SCHEMAS: Record<string, unknown> = {
],
"type": "object"
},
"AmneziaWGLogs": {
"description": "AmneziaWGLogs is what the overview's AmneziaWG log view renders: the live\nper-peer activity of every running embedded interface, plus the panel's\nown recent AmneziaWG lifecycle log lines that explain a peer being absent\nfrom Peers at all.",
"properties": {
"events": {
"example": [
"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
],
"items": {
"type": "string"
},
"type": "array"
},
"peers": {
"items": {
"$ref": "#/components/schemas/PeerActivity"
},
"type": "array"
},
"running": {
"example": true,
"type": "boolean"
}
},
"required": [
"events",
"peers",
"running"
],
"type": "object"
},
"ApiToken": {
"properties": {
"createdAt": {
@@ -2617,6 +2103,14 @@ export const SCHEMAS: Record<string, unknown> = {
"name": {
"type": "string"
},
"nodeType": {
"description": "NodeType distinguishes traditional panel-to-panel nodes from lightweight\nagent nodes. Agent nodes skip the panel reachability probe and the API\ntoken requirement; an agent token is issued on creation instead.",
"enum": [
"traditional",
"agent"
],
"type": "string"
},
"outboundTag": {
"type": "string"
},
@@ -2625,7 +2119,7 @@ export const SCHEMAS: Record<string, unknown> = {
},
"port": {
"maximum": 65535,
"minimum": 1,
"minimum": 0,
"type": "integer"
},
"remark": {
@@ -2778,6 +2272,11 @@ export const SCHEMAS: Record<string, unknown> = {
"format": "int64",
"type": "integer"
},
"nodeType": {
"description": "NodeType is \"agent\" for lightweight agent-managed nodes (an agent is\nbound to the node) and \"traditional\" otherwise. It is derived from the\nagent binding, not stored on the node row; read paths that serve the\npanel UI annotate it via annotateNodeTypes. Empty on views built\noutside those paths.",
"example": "traditional",
"type": "string"
},
"onlineCount": {
"example": 5,
"type": "integer"
@@ -2945,68 +2444,6 @@ export const SCHEMAS: Record<string, unknown> = {
],
"type": "object"
},
"PeerActivity": {
"description": "PeerActivity is one peer's live embedded-Device-reported state, the\ncounterpart of an Xray access-log entry: a tunnel logs no requests, only\nhandshakes and bytes.",
"properties": {
"allowedIPs": {
"example": "10.8.1.2/32",
"type": "string"
},
"down": {
"example": 4194304,
"format": "int64",
"type": "integer"
},
"email": {
"example": "peer@example.com",
"type": "string"
},
"endpoint": {
"example": "203.0.113.9:51820",
"type": "string"
},
"handshake": {
"description": "Handshake is unix milliseconds, 0 when the peer has never connected.",
"example": 1735732800000,
"format": "int64",
"type": "integer"
},
"inboundId": {
"example": 1,
"type": "integer"
},
"interface": {
"example": "awg1",
"type": "string"
},
"online": {
"example": true,
"type": "boolean"
},
"tag": {
"example": "inbound-51820",
"type": "string"
},
"up": {
"example": 1048576,
"format": "int64",
"type": "integer"
}
},
"required": [
"allowedIPs",
"down",
"email",
"endpoint",
"handshake",
"inboundId",
"interface",
"online",
"tag",
"up"
],
"type": "object"
},
"ProbeResultUI": {
"properties": {
"cpuPct": {
+3 -133
View File
@@ -4,6 +4,7 @@ export type OnlineAPISupport = number;
export type ProcessState = string;
export type Protocol = string;
export type SubLinkProvider = unknown;
export type rawBytes = number[];
export type staticEgressResolver = string;
export type trafficLocalApplyAction = number;
export type transportBits = number;
@@ -11,64 +12,24 @@ export type transportBits = number;
export interface AllSetting {
datepicker: string;
expireDiff: number;
externalTrafficInformEnable: boolean;
externalTrafficInformURI: string;
ipLimitAllowlist: string;
ldapAutoCreate: boolean;
ldapAutoDelete: boolean;
ldapBaseDN: string;
ldapBindDN: string;
ldapDefaultExpiryDays: number;
ldapDefaultLimitIP: number;
ldapDefaultTotalGB: number;
ldapEnable: boolean;
ldapFlagField: string;
ldapHost: string;
ldapInboundTags: string;
ldapInsecureSkipVerify: boolean;
ldapInvertFlag: boolean;
ldapPassword: string;
ldapPort: number;
ldapSyncCron: string;
ldapTruthyValues: string;
ldapUseTLS: boolean;
ldapUserAttr: string;
ldapUserFilter: string;
ldapVlessField: string;
outboundDownThreshold: number;
pageSize: number;
panelOutbound: string;
remarkTemplate: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
smtpCpu: number;
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpFrom: string;
smtpFromName: string;
smtpHost: string;
smtpMemory: number;
smtpPassword: string;
smtpPort: number;
smtpTo: string;
smtpUsername: string;
subAnnounce: string;
subCertFile: string;
subClashAutoDetect: boolean;
subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string;
subClashRules: string;
subClashURI: string;
subClashUserAgentRegex: string;
subDomain: string;
subEnable: boolean;
subEnableRouting: boolean;
subEncrypt: boolean;
subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean;
subJsonEnable: boolean;
@@ -76,7 +37,6 @@ export interface AllSetting {
subJsonMux: string;
subJsonObservatory: string;
subJsonPath: string;
subJsonRules: string;
subJsonURI: string;
subJsonUserAgentRegex: string;
subKeyFile: string;
@@ -84,29 +44,15 @@ export interface AllSetting {
subPath: string;
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowIdentityOnAllLinks: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
subURI: string;
subUpdates: number;
tgBotAPIServer: string;
tgBotBackup: boolean;
tgBotChatId: string;
tgBotEnable: boolean;
tgBotProxy: string;
tgBotToken: string;
tgCpu: number;
tgEnabledEvents: string;
tgLang: string;
tgMemory: number;
tgRunTime: string;
timeLocation: string;
trafficDiff: number;
trustedProxyCIDRs: string;
twoFactorEnable: boolean;
twoFactorToken: string;
warpUpdateInterval: number;
webBasePath: string;
webCertFile: string;
@@ -119,71 +65,27 @@ export interface AllSetting {
export interface AllSettingView {
datepicker: string;
expireDiff: number;
externalTrafficInformEnable: boolean;
externalTrafficInformURI: string;
hasApiToken: boolean;
hasLdapPassword: boolean;
hasNordSecret: boolean;
hasSmtpPassword: boolean;
hasTgBotToken: boolean;
hasTwoFactorToken: boolean;
hasWarpSecret: boolean;
ipLimitAllowlist: string;
ldapAutoCreate: boolean;
ldapAutoDelete: boolean;
ldapBaseDN: string;
ldapBindDN: string;
ldapDefaultExpiryDays: number;
ldapDefaultLimitIP: number;
ldapDefaultTotalGB: number;
ldapEnable: boolean;
ldapFlagField: string;
ldapHost: string;
ldapInboundTags: string;
ldapInsecureSkipVerify: boolean;
ldapInvertFlag: boolean;
ldapPassword: string;
ldapPort: number;
ldapSyncCron: string;
ldapTruthyValues: string;
ldapUseTLS: boolean;
ldapUserAttr: string;
ldapUserFilter: string;
ldapVlessField: string;
outboundDownThreshold: number;
pageSize: number;
panelOutbound: string;
remarkTemplate: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
smtpCpu: number;
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpFrom: string;
smtpFromName: string;
smtpHost: string;
smtpMemory: number;
smtpPassword: string;
smtpPort: number;
smtpTo: string;
smtpUsername: string;
subAnnounce: string;
subCertFile: string;
subClashAutoDetect: boolean;
subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string;
subClashRules: string;
subClashURI: string;
subClashUserAgentRegex: string;
subDomain: string;
subEnable: boolean;
subEnableRouting: boolean;
subEncrypt: boolean;
subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean;
subJsonEnable: boolean;
@@ -191,7 +93,6 @@ export interface AllSettingView {
subJsonMux: string;
subJsonObservatory: string;
subJsonPath: string;
subJsonRules: string;
subJsonURI: string;
subJsonUserAgentRegex: string;
subKeyFile: string;
@@ -199,29 +100,15 @@ export interface AllSettingView {
subPath: string;
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowIdentityOnAllLinks: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
subURI: string;
subUpdates: number;
tgBotAPIServer: string;
tgBotBackup: boolean;
tgBotChatId: string;
tgBotEnable: boolean;
tgBotProxy: string;
tgBotToken: string;
tgCpu: number;
tgEnabledEvents: string;
tgLang: string;
tgMemory: number;
tgRunTime: string;
timeLocation: string;
trafficDiff: number;
trustedProxyCIDRs: string;
twoFactorEnable: boolean;
twoFactorToken: string;
warpUpdateInterval: number;
webBasePath: string;
webCertFile: string;
@@ -231,12 +118,6 @@ export interface AllSettingView {
webPort: number;
}
export interface AmneziaWGLogs {
events: string[];
peers: PeerActivity[];
running: boolean;
}
export interface ApiToken {
createdAt: number;
enabled: boolean;
@@ -601,6 +482,7 @@ export interface NodeMutationRequest {
inboundSyncMode: string;
inboundTags: string[];
name: string;
nodeType?: string;
outboundTag: string;
pinnedCertSha256: string;
port: number;
@@ -635,6 +517,7 @@ export interface NodeView {
name: string;
netDown: number;
netUp: number;
nodeType?: string;
onlineCount: number;
outboundTag: string;
panelVersion: string;
@@ -668,19 +551,6 @@ export interface PanelUpdateStatus {
state: string;
}
export interface PeerActivity {
allowedIPs: string;
down: number;
email: string;
endpoint: string;
handshake: number;
inboundId: number;
interface: string;
online: boolean;
tag: string;
up: number;
}
export interface ProbeResultUI {
cpuPct: number;
error: string;
+6 -136
View File
@@ -15,6 +15,9 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
export const SubLinkProviderSchema = z.unknown();
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
export const rawBytesSchema = z.array(z.number().int());
export type rawBytes = z.infer<typeof rawBytesSchema>;
export const staticEgressResolverSchema = z.string();
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
@@ -27,64 +30,24 @@ export type transportBits = z.infer<typeof transportBitsSchema>;
export const AllSettingSchema = z.object({
datepicker: z.string(),
expireDiff: z.number().int().min(0),
externalTrafficInformEnable: z.boolean(),
externalTrafficInformURI: z.string(),
ipLimitAllowlist: z.string(),
ldapAutoCreate: z.boolean(),
ldapAutoDelete: z.boolean(),
ldapBaseDN: z.string(),
ldapBindDN: z.string(),
ldapDefaultExpiryDays: z.number().int().min(0),
ldapDefaultLimitIP: z.number().int().min(0),
ldapDefaultTotalGB: z.number().int().min(0),
ldapEnable: z.boolean(),
ldapFlagField: z.string(),
ldapHost: z.string(),
ldapInboundTags: z.string(),
ldapInsecureSkipVerify: z.boolean(),
ldapInvertFlag: z.boolean(),
ldapPassword: z.string(),
ldapPort: z.number().int().min(0).max(65535),
ldapSyncCron: z.string(),
ldapTruthyValues: z.string(),
ldapUseTLS: z.boolean(),
ldapUserAttr: z.string(),
ldapUserFilter: z.string(),
ldapVlessField: z.string(),
outboundDownThreshold: z.number().int().min(1).max(100),
pageSize: z.number().int().min(0).max(1000),
panelOutbound: z.string(),
remarkTemplate: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(1).max(525600),
smtpCpu: z.number().int().min(0).max(100),
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpFrom: z.string(),
smtpFromName: z.string(),
smtpHost: z.string(),
smtpMemory: z.number().int().min(0).max(100),
smtpPassword: z.string(),
smtpPort: z.number().int().min(1).max(65535),
smtpTo: z.string(),
smtpUsername: z.string(),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashAutoDetect: z.boolean(),
subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(),
subClashUserAgentRegex: z.string(),
subDomain: z.string(),
subEnable: z.boolean(),
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(),
subJsonEnable: z.boolean(),
@@ -92,7 +55,6 @@ export const AllSettingSchema = z.object({
subJsonMux: z.string(),
subJsonObservatory: z.string(),
subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(),
subJsonUserAgentRegex: z.string(),
subKeyFile: z.string(),
@@ -100,29 +62,15 @@ export const AllSettingSchema = z.object({
subPath: z.string(),
subPort: z.number().int().min(0).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(),
subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(),
subURI: z.string(),
subUpdates: z.number().int().min(0).max(525600),
tgBotAPIServer: z.string(),
tgBotBackup: z.boolean(),
tgBotChatId: z.string(),
tgBotEnable: z.boolean(),
tgBotProxy: z.string(),
tgBotToken: z.string(),
tgCpu: z.number().int().min(0).max(100),
tgEnabledEvents: z.string(),
tgLang: z.string(),
tgMemory: z.number().int().min(0).max(100),
tgRunTime: z.string(),
timeLocation: z.string(),
trafficDiff: z.number().int().min(0).max(100),
trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(),
twoFactorToken: z.string(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(),
webCertFile: z.string(),
@@ -136,71 +84,27 @@ export type AllSetting = z.infer<typeof AllSettingSchema>;
export const AllSettingViewSchema = z.object({
datepicker: z.string(),
expireDiff: z.number().int().min(0),
externalTrafficInformEnable: z.boolean(),
externalTrafficInformURI: z.string(),
hasApiToken: z.boolean(),
hasLdapPassword: z.boolean(),
hasNordSecret: z.boolean(),
hasSmtpPassword: z.boolean(),
hasTgBotToken: z.boolean(),
hasTwoFactorToken: z.boolean(),
hasWarpSecret: z.boolean(),
ipLimitAllowlist: z.string(),
ldapAutoCreate: z.boolean(),
ldapAutoDelete: z.boolean(),
ldapBaseDN: z.string(),
ldapBindDN: z.string(),
ldapDefaultExpiryDays: z.number().int().min(0),
ldapDefaultLimitIP: z.number().int().min(0),
ldapDefaultTotalGB: z.number().int().min(0),
ldapEnable: z.boolean(),
ldapFlagField: z.string(),
ldapHost: z.string(),
ldapInboundTags: z.string(),
ldapInsecureSkipVerify: z.boolean(),
ldapInvertFlag: z.boolean(),
ldapPassword: z.string(),
ldapPort: z.number().int().min(0).max(65535),
ldapSyncCron: z.string(),
ldapTruthyValues: z.string(),
ldapUseTLS: z.boolean(),
ldapUserAttr: z.string(),
ldapUserFilter: z.string(),
ldapVlessField: z.string(),
outboundDownThreshold: z.number().int().min(1).max(100),
pageSize: z.number().int().min(0).max(1000),
panelOutbound: z.string(),
remarkTemplate: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(1).max(525600),
smtpCpu: z.number().int().min(0).max(100),
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpFrom: z.string(),
smtpFromName: z.string(),
smtpHost: z.string(),
smtpMemory: z.number().int().min(0).max(100),
smtpPassword: z.string(),
smtpPort: z.number().int().min(1).max(65535),
smtpTo: z.string(),
smtpUsername: z.string(),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashAutoDetect: z.boolean(),
subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(),
subClashUserAgentRegex: z.string(),
subDomain: z.string(),
subEnable: z.boolean(),
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(),
subJsonEnable: z.boolean(),
@@ -208,7 +112,6 @@ export const AllSettingViewSchema = z.object({
subJsonMux: z.string(),
subJsonObservatory: z.string(),
subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(),
subJsonUserAgentRegex: z.string(),
subKeyFile: z.string(),
@@ -216,29 +119,15 @@ export const AllSettingViewSchema = z.object({
subPath: z.string(),
subPort: z.number().int().min(0).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(),
subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(),
subURI: z.string(),
subUpdates: z.number().int().min(0).max(525600),
tgBotAPIServer: z.string(),
tgBotBackup: z.boolean(),
tgBotChatId: z.string(),
tgBotEnable: z.boolean(),
tgBotProxy: z.string(),
tgBotToken: z.string(),
tgCpu: z.number().int().min(0).max(100),
tgEnabledEvents: z.string(),
tgLang: z.string(),
tgMemory: z.number().int().min(0).max(100),
tgRunTime: z.string(),
timeLocation: z.string(),
trafficDiff: z.number().int().min(0).max(100),
trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(),
twoFactorToken: z.string(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(),
webCertFile: z.string(),
@@ -249,13 +138,6 @@ export const AllSettingViewSchema = z.object({
});
export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
export const AmneziaWGLogsSchema = z.object({
events: z.array(z.string()),
peers: z.array(z.lazy(() => PeerActivitySchema)),
running: z.boolean(),
});
export type AmneziaWGLogs = z.infer<typeof AmneziaWGLogsSchema>;
export const ApiTokenSchema = z.object({
createdAt: z.number().int(),
enabled: z.boolean(),
@@ -643,9 +525,10 @@ export const NodeMutationRequestSchema = z.object({
inboundSyncMode: z.enum(['all', 'selected']),
inboundTags: z.array(z.string()),
name: z.string(),
nodeType: z.enum(['traditional', 'agent']).optional(),
outboundTag: z.string(),
pinnedCertSha256: z.string(),
port: z.number().int().min(1).max(65535),
port: z.number().int().min(0).max(65535),
remark: z.string(),
scheme: z.enum(['http', 'https']),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
@@ -678,6 +561,7 @@ export const NodeViewSchema = z.object({
name: z.string(),
netDown: z.number().int(),
netUp: z.number().int(),
nodeType: z.string().optional(),
onlineCount: z.number().int(),
outboundTag: z.string(),
panelVersion: z.string(),
@@ -714,20 +598,6 @@ export const PanelUpdateStatusSchema = z.object({
});
export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>;
export const PeerActivitySchema = z.object({
allowedIPs: z.string(),
down: z.number().int(),
email: z.string(),
endpoint: z.string(),
handshake: z.number().int(),
inboundId: z.number().int(),
interface: z.string(),
online: z.boolean(),
tag: z.string(),
up: z.number().int(),
});
export type PeerActivity = z.infer<typeof PeerActivitySchema>;
export const ProbeResultUISchema = z.object({
cpuPct: z.number(),
error: z.string(),
+5 -281
View File
@@ -7,46 +7,15 @@ import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
import {
OutboundTrafficListSchema,
OutboundTestResultListSchema,
XrayConfigPayloadSchema,
XraySettingsValueSchema,
type OutboundTestResult,
type OutboundTrafficRow,
} from '@/schemas/xray';
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
// One HTTP-mode batch request tests this many outbounds through a single
// shared temp xray instance; chunking keeps responses bounded (~30s worst
// case — each probe is a cold plus a warm request) and lands Test All
// results progressively.
const HTTP_BATCH_CHUNK = 16;
function normalizeOutboundTestUrl(url: string) {
return url || DEFAULT_TEST_URL;
}
export function isUdpOutbound(outbound: unknown): boolean {
const o = outbound as
| { protocol?: string; streamSettings?: { network?: string } }
| null
| undefined;
const p = o?.protocol;
const n = o?.streamSettings?.network;
return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
}
export type OutboundTestMode = 'tcp' | 'http' | 'real';
export type { OutboundTrafficRow, OutboundTestResult };
export type { OutboundTrafficRow };
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
export interface OutboundTestState {
testing?: boolean;
result?: OutboundTestResult | null;
mode?: string;
}
export type SetTemplate = (
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
) => void;
@@ -60,30 +29,14 @@ export interface UseXraySettingResult {
setXraySetting: (next: string) => void;
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundTestUrl: string;
setOutboundTestUrl: (v: string) => void;
inboundTags: string[];
clientReverseTags: string[];
subscriptionOutbounds: unknown[];
subscriptionOutboundTags: string[];
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
subscriptionTestStates: Record<string, OutboundTestState>;
testingAll: boolean;
fetchAll: () => Promise<void>;
fetchOutboundsTraffic: () => Promise<void>;
resetOutboundsTraffic: (tag: string) => Promise<void>;
testOutbound: (
index: number,
outbound: unknown,
mode?: string,
) => Promise<OutboundTestResult | null>;
testSubscriptionOutbound: (
tag: string,
outbound: unknown,
mode?: string,
) => Promise<OutboundTestResult | null>;
testAllOutbounds: (mode?: string) => Promise<void>;
saveAll: () => Promise<void>;
resetToDefault: () => Promise<void>;
}
@@ -136,9 +89,7 @@ export function useXraySetting(): UseXraySettingResult {
const [xraySetting, setXraySettingState] = useState('');
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
const [savedXraySetting, setSavedXraySetting] = useState('');
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
const config = configQuery.data;
const inboundTags = useMemo(() => config?.inboundTags || [], [config]);
const clientReverseTags = useMemo(() => config?.clientReverseTags || [], [config]);
@@ -147,21 +98,10 @@ export function useXraySetting(): UseXraySettingResult {
[config],
);
const subscriptionOutboundTags = useMemo(() => config?.subscriptionOutboundTags || [], [config]);
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>(
{},
);
// Subscription outbounds aren't in templateSettings.outbounds, so their test
// results are keyed by tag rather than by index.
const [subscriptionTestStates, setSubscriptionTestStates] = useState<
Record<string, OutboundTestState>
>({});
const [testingAll, setTestingAll] = useState(false);
const syncingRef = useRef(false);
const xraySettingRef = useRef('');
const outboundTestUrlRef = useRef(outboundTestUrl);
const savedXraySettingRef = useRef(savedXraySetting);
const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
const subscriptionOutboundsRef = useRef<unknown[]>([]);
@@ -169,9 +109,7 @@ export function useXraySetting(): UseXraySettingResult {
useEffect(() => {
xraySettingRef.current = xraySetting;
outboundTestUrlRef.current = outboundTestUrl;
savedXraySettingRef.current = savedXraySetting;
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
templateSettingsRef.current = templateSettings;
subscriptionOutboundsRef.current = subscriptionOutbounds;
});
@@ -180,17 +118,12 @@ export function useXraySetting(): UseXraySettingResult {
// of the previous config after a refetch. Local edits win over the refetch.
if (config && config !== syncedConfig) {
setSyncedConfig(config);
const isDirty =
savedXraySetting !== xraySetting ||
savedOutboundTestUrl !== normalizeOutboundTestUrl(outboundTestUrl);
const isDirty = savedXraySetting !== xraySetting;
if (!isDirty) {
const pretty = JSON.stringify(config.xraySetting, null, 2);
const nextUrl = normalizeOutboundTestUrl(config.outboundTestUrl || '');
setXraySettingState(pretty);
setTemplateSettingsState(config.xraySetting);
setSavedXraySetting(pretty);
setOutboundTestUrlState(nextUrl);
setSavedOutboundTestUrl(nextUrl);
}
}
@@ -226,10 +159,6 @@ export function useXraySetting(): UseXraySettingResult {
});
}, []);
const setOutboundTestUrl = useCallback((v: string) => {
setOutboundTestUrlState(v);
}, []);
const fetchAll = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
}, [queryClient]);
@@ -241,17 +170,14 @@ export function useXraySetting(): UseXraySettingResult {
const saveMut = useMutation({
mutationFn: async () => {
const sentXraySetting = xraySettingRef.current;
const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
const msg = await HttpUtil.post('/panel/api/xray/update', {
xraySetting: sentXraySetting,
outboundTestUrl: sentTestUrl,
});
return { msg, sentXraySetting, sentTestUrl };
return { msg, sentXraySetting };
},
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
onSuccess: ({ msg, sentXraySetting }) => {
if (!msg?.success) return;
setSavedXraySetting(sentXraySetting);
setSavedOutboundTestUrl(sentTestUrl);
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
},
});
@@ -291,193 +217,7 @@ export function useXraySetting(): UseXraySettingResult {
const spinning = saveMut.isPending || resetDefaultMut.isPending;
// Shared POST + parse for a batch of outbound tests. The backend probes the
// whole batch through one shared temp xray instance and returns results in
// request order; this aligns them by index and shapes failures so every
// input gets an OutboundTestResult.
const postOutboundTestBatch = useCallback(
async (outbounds: unknown[], effMode: string): Promise<OutboundTestResult[]> => {
const failAll = (error: string): OutboundTestResult[] =>
outbounds.map(() => ({ success: false, error, mode: effMode }));
try {
const raw = await HttpUtil.post('/panel/api/xray/testOutbounds', {
outbounds: JSON.stringify(outbounds),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode: effMode,
});
const msg = parseMsg(raw, OutboundTestResultListSchema, 'xray/testOutbounds');
if (!msg?.success || !Array.isArray(msg.obj)) return failAll(msg?.msg || 'Unknown error');
const list = msg.obj;
return outbounds.map(
(_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode },
);
} catch (e) {
return failAll(String(e));
}
},
[],
);
const testOutbound = useCallback(
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
if (!outbound) return null;
const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
setOutboundTestStates((prev) => ({
...prev,
[index]: { testing: true, result: null, mode: effMode },
}));
const [result] = await postOutboundTestBatch([outbound], effMode);
setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
return result.success ? result : null;
},
[postOutboundTestBatch],
);
// Test a subscription outbound (not present in templateSettings.outbounds);
// results are keyed by tag in subscriptionTestStates.
const testSubscriptionOutbound = useCallback(
async (tag: string, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
if (!outbound || !tag) return null;
const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
setSubscriptionTestStates((prev) => ({
...prev,
[tag]: { testing: true, result: null, mode: effMode },
}));
const [result] = await postOutboundTestBatch([outbound], effMode);
setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
return result.success ? result : null;
},
[postOutboundTestBatch],
);
const testAllOutbounds = useCallback(
async (mode = 'tcp') => {
// Template outbounds key their results by index (outboundTestStates);
// subscription outbounds aren't in the template, so they key by tag
// (subscriptionTestStates). Both go through the same probe endpoint.
const templateList = templateSettingsRef.current?.outbounds || [];
const subList = (subscriptionOutboundsRef.current || []) as Array<{
tag?: string;
protocol?: string;
}>;
if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
setTestingAll(true);
try {
type TcpEntry =
| { kind: 'tpl'; index: number; outbound: unknown }
| { kind: 'sub'; tag: string; outbound: unknown };
const tcpQueue: TcpEntry[] = [];
// HTTP batches stay homogeneous (all template or all subscription) so a
// tag shared between a template and a subscription outbound can't collide
// inside one batch, and each batch's results route to one state map.
const probeMode = mode === 'real' ? 'real' : 'http';
const httpTplQueue: { index: number; outbound: unknown }[] = [];
const httpSubQueue: { tag: string; outbound: unknown }[] = [];
const enqueue = (
ob: { tag?: string; protocol?: string },
kind: 'tpl' | 'sub',
index: number,
tag: string,
) => {
const proto = ob?.protocol;
if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
// freedom ("direct") and dns aren't proxies — skip them in every mode.
if (proto === 'freedom' || proto === 'dns') return;
if (kind === 'sub' && !tag) return;
const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
if (kind === 'tpl') {
if (toHttp) httpTplQueue.push({ index, outbound: ob });
else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
} else if (toHttp) {
httpSubQueue.push({ tag, outbound: ob });
} else {
tcpQueue.push({ kind: 'sub', tag, outbound: ob });
}
};
templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
// TCP probes are dial-only and cheap server-side; per-item requests
// keep results landing one by one, each routed to its own state map.
const runTcpLane = async () => {
const queue = [...tcpQueue];
const worker = async () => {
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
if (item.kind === 'sub')
await testSubscriptionOutbound(item.tag, item.outbound, mode);
else await testOutbound(item.index, item.outbound, mode);
}
};
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
};
// HTTP probes go out as chunked batches — one temp xray spawn per
// chunk instead of one per outbound, with results landing per chunk.
const runTplHttpLane = async () => {
for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
setOutboundTestStates((prev) => {
const next = { ...prev };
for (const item of chunk)
next[item.index] = { testing: true, result: null, mode: probeMode };
return next;
});
const results = await postOutboundTestBatch(
chunk.map((c) => c.outbound),
probeMode,
);
setOutboundTestStates((prev) => {
const next = { ...prev };
chunk.forEach((item, i) => {
next[item.index] = { testing: false, result: results[i] };
});
return next;
});
}
};
const runSubHttpLane = async () => {
for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
setSubscriptionTestStates((prev) => {
const next = { ...prev };
for (const item of chunk)
next[item.tag] = { testing: true, result: null, mode: probeMode };
return next;
});
const results = await postOutboundTestBatch(
chunk.map((c) => c.outbound),
probeMode,
);
setSubscriptionTestStates((prev) => {
const next = { ...prev };
chunk.forEach((item, i) => {
next[item.tag] = { testing: false, result: results[i] };
});
return next;
});
}
};
// HTTP batches must not overlap: the backend serialises them with a
// non-blocking lock and rejects a second concurrent batch ("Another
// outbound test is already running"). Run the template and subscription
// HTTP lanes one after the other; TCP probes don't take that lock, so
// they still run alongside.
const runHttpLane = async () => {
await runTplHttpLane();
await runSubHttpLane();
};
await Promise.all([runTcpLane(), runHttpLane()]);
} finally {
setTestingAll(false);
}
},
[testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch],
);
const saveDisabled =
savedXraySetting === xraySetting &&
savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
const saveDisabled = savedXraySetting === xraySetting;
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
@@ -491,22 +231,14 @@ export function useXraySetting(): UseXraySettingResult {
setXraySetting,
templateSettings,
setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll,
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
}),
@@ -519,22 +251,14 @@ export function useXraySetting(): UseXraySettingResult {
setXraySetting,
templateSettings,
setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll,
fetchOutboundsTrafficCb,
resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
],
-13
View File
@@ -1,16 +1,3 @@
.xray-page .restart-icon {
font-size: 16px;
cursor: pointer;
color: var(--ant-color-primary);
}
.xray-page .restart-result {
max-width: 480px;
white-space: pre-wrap;
font-size: 12px;
margin: 0;
}
.xray-page .advanced-meta h4 {
margin: 0 0 4px;
}
-26
View File
@@ -55,21 +55,13 @@ export default function XrayPage() {
setXraySetting,
templateSettings,
setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll,
resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
} = xs;
@@ -99,16 +91,6 @@ export default function XrayPage() {
[setTemplateSettings],
);
async function onTestOutbound(idx: number, mode: string) {
const outbound = templateSettings?.outbounds?.[idx];
if (outbound) await testOutbound(idx, outbound, mode);
}
async function onTestSubscription(outbound: Record<string, unknown>, mode: string) {
const tag = typeof outbound?.tag === 'string' ? outbound.tag : '';
if (tag) await testSubscriptionOutbound(tag, outbound, mode);
}
function onAddOutbound(outbound: Record<string, unknown>) {
mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
@@ -196,17 +178,11 @@ export default function XrayPage() {
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
outboundsTraffic={outboundsTraffic}
outboundTestStates={outboundTestStates}
subscriptionTestStates={subscriptionTestStates}
testingAll={testingAll}
inboundTags={inboundTags}
subscriptionOutbounds={subscriptionOutbounds}
subscriptionOutboundTags={subscriptionOutboundTags}
isMobile={isMobile}
onResetTraffic={resetOutboundsTraffic}
onTest={onTestOutbound}
onTestSubscription={onTestSubscription}
onTestAll={testAllOutbounds}
onShowWarp={() => setWarpOpen(true)}
onShowNord={() => setNordOpen(true)}
onShowPia={() => setPiaOpen(true)}
@@ -218,8 +194,6 @@ export default function XrayPage() {
<BasicsTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
outboundTestUrl={outboundTestUrl}
onChangeOutboundTestUrl={setOutboundTestUrl}
onResetDefault={resetToDefault}
/>
);
@@ -29,16 +29,12 @@ import {
interface BasicsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundTestUrl: string;
onChangeOutboundTestUrl: (v: string) => void;
onResetDefault: () => void;
}
export default function BasicsTab({
templateSettings,
setTemplateSettings,
outboundTestUrl,
onChangeOutboundTestUrl,
onResetDefault,
}: BasicsTabProps) {
const { t } = useTranslation();
@@ -276,18 +272,6 @@ export default function BasicsTab({
/>
}
/>
<SettingListItem
title={t('pages.xray.outboundTestUrl')}
description={t('pages.xray.outboundTestUrlDesc')}
paddings="small"
control={
<Input
value={outboundTestUrl}
onChange={(e) => onChangeOutboundTestUrl(e.target.value)}
placeholder="https://www.google.com/generate_204"
/>
}
/>
</>
),
},
@@ -1,18 +0,0 @@
import { CloudOutlined } from '@ant-design/icons';
interface CountryPillProps {
flag: string;
name: string;
warp?: string;
}
export default function CountryPill({ flag, name, warp }: CountryPillProps) {
const isWarp = !!warp && warp.toLowerCase() !== 'off';
return (
<span className={isWarp ? 'country-pill warp-on' : 'country-pill'}>
{isWarp && <CloudOutlined className="warp-cloud-icon" />}
{flag && <span>{flag}</span>}
<span>{name}</span>
</span>
);
}
@@ -1,4 +1,3 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Tag, Tooltip } from 'antd';
import {
@@ -7,127 +6,34 @@ import {
EditOutlined,
DeleteOutlined,
VerticalAlignTopOutlined,
ThunderboltOutlined,
LoadingOutlined,
ExportOutlined,
EyeInvisibleOutlined,
EyeOutlined,
} from '@ant-design/icons';
import { SizeFormatter } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type {
OutboundTestMode,
OutboundTestState,
OutboundTrafficRow,
} from '@/hooks/useXraySetting';
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import CountryPill from './CountryPill';
import TestResultPopover from './TestResultPopover';
import {
countryFlag,
countryName,
isTesting,
isUntestable,
outboundAddresses,
showSecurity,
testResult,
trafficFor,
} from './outbounds-tab-helpers';
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
interface OutboundCardListProps {
rows: OutboundRow[];
testMode: OutboundTestMode;
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
setFirst: (idx: number) => void;
openEdit: (idx: number) => void;
onResetTraffic: (tag: string) => void;
confirmDelete: (idx: number) => void;
onTest: (index: number, mode: string) => void;
}
export default function OutboundCardList({
rows,
testMode,
outboundsTraffic,
outboundTestStates,
setFirst,
openEdit,
onResetTraffic,
confirmDelete,
onTest,
}: OutboundCardListProps) {
const { t, i18n } = useTranslation();
const [showEgressIp, setShowEgressIp] = useState<Record<string, boolean>>({});
const setCardEgressVisible = (key: string, visible: boolean) => {
setShowEgressIp((prev) => ({ ...prev, [key]: visible }));
};
const renderEgress = (testKey: number, rowKey: string) => {
const result = testResult(outboundTestStates, testKey);
const egress = result?.egress;
const isEgressVisible = !!showEgressIp[rowKey];
const flag = countryFlag(egress?.country);
const name = countryName(egress?.country, i18n.language);
const addresses = [
egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
].filter((item): item is { label: string; value: string } => Boolean(item));
if (!egress || (addresses.length === 0 && !egress.country)) {
return null;
}
return (
<div className="card-egress">
<div className="card-egress-row">
<span>{t('pages.xray.outbound.egress')}:</span>
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{isEgressVisible ? (
<EyeOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setCardEgressVisible(rowKey, false)}
onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, false))}
/>
) : (
<EyeInvisibleOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setCardEgressVisible(rowKey, true)}
onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, true))}
/>
)}
</Tooltip>
{egress.country && (
<CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
)}
</div>
{addresses.map((addr) => (
<Tooltip key={addr.label} title={addr.value}>
<div className="card-egress-row">
<span className="egress-family">{addr.label}:</span>
<span
className={
isEgressVisible ? 'address-visible egress-ip' : 'address-hidden egress-ip'
}
>
{addr.value}
</span>
</div>
</Tooltip>
))}
</div>
);
};
const { t } = useTranslation();
if (rows.length === 0) {
return (
@@ -219,7 +125,6 @@ export default function OutboundCardList({
))}
</div>
)}
{renderEgress(record.key, String(record.key))}
<div className="card-foot">
<span className="traffic-up">
↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}
@@ -228,23 +133,6 @@ export default function OutboundCardList({
<span className="traffic-down">
↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}
</span>
<span className="card-test">
{testResult(outboundTestStates, record.key) ? (
<TestResultPopover result={testResult(outboundTestStates, record.key)!} />
) : isTesting(outboundTestStates, record.key) ? (
<LoadingOutlined />
) : null}
<Button
type="primary"
shape="circle"
size="small"
loading={isTesting(outboundTestStates, record.key)}
disabled={isUntestable(record) || isTesting(outboundTestStates, record.key)}
icon={<ThunderboltOutlined />}
aria-label={t('check')}
onClick={() => onTest(record.key, testMode)}
/>
</span>
</div>
</div>
))}
@@ -75,51 +75,8 @@
background: var(--ant-color-fill-tertiary);
}
.egress-header {
display: inline-flex;
align-items: center;
gap: 4px;
}
.egress-ip {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.egress-stack {
display: inline-flex;
flex-direction: column;
gap: 3px;
}
.egress-address {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.egress-family {
color: var(--ant-color-text-secondary);
font-size: 10px;
text-transform: uppercase;
}
.country-pill {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid var(--ant-color-border);
border-radius: 4px;
padding: 1px 6px;
line-height: 20px;
font-size: 12px;
white-space: nowrap;
}
.country-pill.warp-on {
border-color: #f48120;
}
.warp-cloud-icon {
width: 1em;
@@ -185,30 +142,6 @@
flex-wrap: wrap;
}
.card-egress {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
font-size: 12px;
opacity: 0.85;
}
.card-egress-row {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
}
.card-test {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 8px;
}
.traffic-up {
color: #008771;
font-size: 12px;
@@ -9,7 +9,6 @@ import {
InputNumber,
Modal,
Popconfirm,
Radio,
Row,
Space,
Switch,
@@ -24,7 +23,6 @@ import {
ApiOutlined,
MoreOutlined,
RetweetOutlined,
PlayCircleOutlined,
ReloadOutlined,
DeleteOutlined,
EditOutlined,
@@ -47,13 +45,7 @@ import { propagateOutboundTagRename } from '../basics/helpers';
import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
import DeletionImpactList from '../DeletionImpactList';
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
import type {
XraySettingsValue,
SetTemplate,
OutboundTestMode,
OutboundTestState,
OutboundTrafficRow,
} from '@/hooks/useXraySetting';
import type { XraySettingsValue, SetTemplate, OutboundTrafficRow } from '@/hooks/useXraySetting';
import './OutboundsTab.css';
import type { OutboundRow } from './outbounds-tab-types';
@@ -82,17 +74,11 @@ interface OutboundsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
subscriptionTestStates: Record<string, OutboundTestState>;
testingAll: boolean;
inboundTags: string[];
subscriptionOutbounds?: unknown[];
subscriptionOutboundTags?: string[];
isMobile: boolean;
onResetTraffic: (tag: string) => void;
onTest: (index: number, mode: string) => void;
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
onTestAll: (mode: string) => void;
onShowWarp: () => void;
onShowNord: () => void;
onShowPia: () => void;
@@ -103,17 +89,11 @@ export default function OutboundsTab({
templateSettings,
setTemplateSettings,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
inboundTags: _inboundTags,
subscriptionOutbounds,
subscriptionOutboundTags,
isMobile,
onResetTraffic,
onTest,
onTestSubscription,
onTestAll,
onShowWarp,
onShowNord,
onShowPia,
@@ -122,7 +102,6 @@ export default function OutboundsTab({
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [testMode, setTestMode] = useState<OutboundTestMode>('tcp');
const [modalOpen, setModalOpen] = useState(false);
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
@@ -519,17 +498,14 @@ export default function OutboundsTab({
}
const columns = useOutboundColumns({
testMode,
rows,
outboundsTraffic,
outboundTestStates,
openEdit,
setFirst,
moveUp,
moveDown,
confirmDelete,
onResetTraffic,
onTest,
});
return (
@@ -581,26 +557,6 @@ export default function OutboundsTab({
</Col>
<Col xs={24} sm={12} className="toolbar-right">
<Space size="small" wrap>
<Tooltip title={t('pages.xray.outbound.testModeTooltip')}>
<Radio.Group
value={testMode}
onChange={(e) => setTestMode(e.target.value)}
buttonStyle="solid"
size="small"
>
<Radio.Button value="tcp">TCP</Radio.Button>
<Radio.Button value="http">HTTP</Radio.Button>
<Radio.Button value="real">{t('pages.xray.outbound.modeRealDelay')}</Radio.Button>
</Radio.Group>
</Tooltip>
<Button
type="primary"
loading={testingAll}
icon={<PlayCircleOutlined />}
onClick={() => onTestAll(testMode)}
>
{!isMobile && t('pages.xray.outbound.testAll')}
</Button>
<Popconfirm
placement="topRight"
okText={t('reset')}
@@ -617,14 +573,11 @@ export default function OutboundsTab({
{isMobile ? (
<OutboundCardList
rows={rows}
testMode={testMode}
outboundsTraffic={outboundsTraffic}
outboundTestStates={outboundTestStates}
setFirst={setFirst}
openEdit={openEdit}
onResetTraffic={onResetTraffic}
confirmDelete={confirmDelete}
onTest={onTest}
/>
) : (
<Table
@@ -675,10 +628,7 @@ export default function OutboundsTab({
<SubscriptionOutbounds
subscriptionOutbounds={subscriptionOutbounds}
outboundsTraffic={outboundsTraffic}
subscriptionTestStates={subscriptionTestStates}
testMode={testMode}
isMobile={isMobile}
onTestSubscription={onTestSubscription}
/>
)}
</Space>
@@ -1,49 +1,28 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Table, Tag, Tooltip } from 'antd';
import { ThunderboltOutlined, LoadingOutlined } from '@ant-design/icons';
import { Table, Tag, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type {
OutboundTestMode,
OutboundTestState,
OutboundTrafficRow,
} from '@/hooks/useXraySetting';
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import TestResultPopover from './TestResultPopover';
import {
effectiveTestMode,
isTesting,
isUntestable,
outboundAddresses,
showSecurity,
testModeLabel,
testResult,
trafficFor,
} from './outbounds-tab-helpers';
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
interface SubscriptionOutboundsProps {
subscriptionOutbounds: unknown[];
outboundsTraffic: OutboundTrafficRow[];
subscriptionTestStates: Record<string, OutboundTestState>;
testMode: OutboundTestMode;
isMobile: boolean;
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
}
// Read-only view of outbounds imported from active subscriptions. They are not
// part of the editable template (so no edit/delete/move), but traffic is matched
// by tag and they can be latency-tested via the same backend endpoint.
// by tag.
export default function SubscriptionOutbounds({
subscriptionOutbounds,
outboundsTraffic,
subscriptionTestStates,
testMode,
isMobile,
onTestSubscription,
}: SubscriptionOutboundsProps) {
const { t } = useTranslation();
@@ -104,36 +83,6 @@ export default function SubscriptionOutbounds({
);
};
const latencyCell = (record: OutboundRow) => {
const key = record.tag || '';
const r = testResult(subscriptionTestStates, key);
if (!r)
return isTesting(subscriptionTestStates, key) ? (
<LoadingOutlined />
) : (
<span className="empty">—</span>
);
return <TestResultPopover result={r} />;
};
const testButton = (record: OutboundRow) => {
const key = record.tag || '';
return (
<Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
<Button
aria-label={t('check')}
type="primary"
shape="circle"
size={isMobile ? 'small' : undefined}
loading={isTesting(subscriptionTestStates, key)}
disabled={!record.tag || isUntestable(record) || isTesting(subscriptionTestStates, key)}
icon={<ThunderboltOutlined />}
onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
/>
</Tooltip>
);
};
const header = (
<div className="subscription-outbounds-head">
<div className="subscription-outbounds-title">
@@ -154,13 +103,9 @@ export default function SubscriptionOutbounds({
<span className="card-num">{index + 1}</span>
{identityCell(record)}
</div>
{testButton(record)}
</div>
{outboundAddresses(record).length > 0 && addressCell(record)}
<div className="card-foot">
{trafficCell(record)}
<span className="card-test">{latencyCell(record)}</span>
</div>
<div className="card-foot">{trafficCell(record)}</div>
</div>
))}
</div>
@@ -194,20 +139,6 @@ export default function SubscriptionOutbounds({
width: 200,
render: (_v, record) => trafficCell(record),
},
{
title: t('pages.nodes.latency'),
key: 'testResult',
align: 'left',
width: 140,
render: (_v, record) => latencyCell(record),
},
{
title: t('check'),
key: 'test',
align: 'center',
width: 80,
render: (_v, record) => testButton(record),
},
];
return (
@@ -1,88 +0,0 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Popover } from 'antd';
import { CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
import type { OutboundTestResult } from '@/hooks/useXraySetting';
import { testModeLabel } from './outbounds-tab-helpers';
interface TestResultPopoverProps {
result: OutboundTestResult;
// Custom trigger element; defaults to the ok/fail latency pill.
children?: ReactNode;
}
// Latency pill + detail popover for an outbound test result: per-endpoint
// dial outcomes for TCP probes, HTTP status and the timing breakdown for
// HTTP probes.
export default function TestResultPopover({ result: r, children }: TestResultPopoverProps) {
const { t } = useTranslation();
const breakdown: Array<{ key: string; label: string; value: string }> = [];
if (typeof r.httpStatus === 'number') {
breakdown.push({
key: 'status',
label: t('pages.xray.outbound.httpStatus'),
value: String(r.httpStatus),
});
}
if (typeof r.connectMs === 'number') {
breakdown.push({
key: 'connect',
label: t('pages.xray.outbound.breakdownConnect'),
value: `${r.connectMs} ms`,
});
}
if (typeof r.tlsMs === 'number') {
breakdown.push({
key: 'tls',
label: t('pages.xray.outbound.breakdownTls'),
value: `${r.tlsMs} ms`,
});
}
if (typeof r.ttfbMs === 'number') {
breakdown.push({
key: 'ttfb',
label: t('pages.xray.outbound.breakdownTtfb'),
value: `${r.ttfbMs} ms`,
});
}
return (
<Popover
placement="topLeft"
rootClassName="outbound-test-popover"
content={
<div className="timing-breakdown">
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
{r.mode && <span className="mode-badge">{testModeLabel(String(r.mode), t)}</span>}
</div>
{(r.endpoints || []).map((ep) => (
<div key={ep.address} className="endpoint-row">
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
<span className="ep-addr">{ep.address}</span>
<span className="ep-meta">
{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}
</span>
</div>
))}
{breakdown.map((row) => (
<div key={row.key} className="breakdown-row">
<span className="bd-label">{row.label}</span>
<span className="bd-value">{row.value}</span>
</div>
))}
</div>
}
>
{children ?? (
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
{r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
</span>
)}
</Popover>
);
}
@@ -1,12 +1,5 @@
import type { TFunction } from 'i18next';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import { isUdpOutbound } from '@/hooks/useXraySetting';
import type {
OutboundTestMode,
OutboundTestState,
OutboundTrafficRow,
} from '@/hooks/useXraySetting';
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
@@ -54,33 +47,10 @@ export function outboundAddresses(o: OutboundRow): string[] {
}
}
export function isUntestable(o: OutboundRow): boolean {
if (!o) return true;
if (
o.protocol === Protocols.Blackhole ||
o.protocol === Protocols.Loopback ||
o.tag === 'blocked'
)
return true;
// freedom ("direct") and dns aren't proxies — a TCP dial has no endpoint and
// an HTTP probe would only measure the host's own direct reachability, so
// they're untestable in every mode.
if (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS) return true;
return false;
}
export function showSecurity(security?: string): boolean {
return security === 'tls' || security === 'reality';
}
export function effectiveTestMode(o: unknown, mode: OutboundTestMode): OutboundTestMode {
return mode === 'tcp' && isUdpOutbound(o) ? 'http' : mode;
}
export function testModeLabel(mode: string, t: TFunction): string {
return mode === 'real' ? t('pages.xray.outbound.modeRealDelay') : mode.toUpperCase();
}
export function trafficFor(
outboundsTraffic: OutboundTrafficRow[],
o: OutboundRow,
@@ -106,17 +76,3 @@ export function countryName(country?: string, locale?: string): string {
return code;
}
}
export function isTesting<K extends string | number>(
states: Record<K, OutboundTestState>,
idx: K,
): boolean {
return !!states?.[idx]?.testing;
}
export function testResult<K extends string | number>(
states: Record<K, OutboundTestState>,
idx: K,
) {
return states?.[idx]?.result || null;
}
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Tag, Tooltip } from 'antd';
import {
@@ -7,69 +7,40 @@ import {
EditOutlined,
DeleteOutlined,
VerticalAlignTopOutlined,
ThunderboltOutlined,
LoadingOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
EyeInvisibleOutlined,
EyeOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type {
OutboundTestMode,
OutboundTestState,
OutboundTrafficRow,
} from '@/hooks/useXraySetting';
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import CountryPill from './CountryPill';
import TestResultPopover from './TestResultPopover';
import {
effectiveTestMode,
countryFlag,
countryName,
isTesting,
isUntestable,
outboundAddresses,
showSecurity,
testModeLabel,
testResult,
trafficFor,
} from './outbounds-tab-helpers';
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
interface OutboundColumnsParams {
testMode: OutboundTestMode;
rows: OutboundRow[];
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
openEdit: (idx: number) => void;
setFirst: (idx: number) => void;
moveUp: (idx: number) => void;
moveDown: (idx: number) => void;
confirmDelete: (idx: number) => void;
onResetTraffic: (tag: string) => void;
onTest: (index: number, mode: string) => void;
}
export function useOutboundColumns({
testMode,
rows,
outboundsTraffic,
outboundTestStates,
openEdit,
setFirst,
moveUp,
moveDown,
confirmDelete,
onResetTraffic,
onTest,
}: OutboundColumnsParams): ColumnsType<OutboundRow> {
const { t, i18n } = useTranslation();
const [showEgressIp, setShowEgressIp] = useState(false);
const { t } = useTranslation();
return useMemo(
() => [
{
@@ -204,94 +175,6 @@ export function useOutboundColumns({
);
},
},
{
title: (
<span className="egress-header">
{t('pages.xray.outbound.egress')}
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showEgressIp ? (
<EyeOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowEgressIp(false)}
onKeyDown={activateOnKey(() => setShowEgressIp(false))}
/>
) : (
<EyeInvisibleOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowEgressIp(true)}
onKeyDown={activateOnKey(() => setShowEgressIp(true))}
/>
)}
</Tooltip>
</span>
),
key: 'egress',
align: 'left',
width: 210,
render: (_v, record) => {
const egress = testResult(outboundTestStates, record.key)?.egress;
const addresses = [
egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
].filter((item): item is { label: string; value: string } => Boolean(item));
if (addresses.length === 0) {
return (
<Tooltip title={t('pages.xray.outbound.egressHint')}>
<span className="empty">—</span>
</Tooltip>
);
}
return (
<div className="egress-stack">
{addresses.map((addr) => (
<Tooltip key={addr.label} title={addr.value}>
<span className="egress-address">
<span className="egress-family">{addr.label}</span>
<span
className={
showEgressIp ? 'address-visible egress-ip' : 'address-hidden egress-ip'
}
>
{addr.value}
</span>
</span>
</Tooltip>
))}
</div>
);
},
},
{
title: t('pages.xray.outbound.country'),
key: 'egressCountry',
align: 'left',
width: 160,
render: (_v, record) => {
const egress = testResult(outboundTestStates, record.key)?.egress;
if (!egress?.country) {
return (
<Tooltip title={t('pages.xray.outbound.egressHint')}>
<span className="empty">—</span>
</Tooltip>
);
}
const flag = countryFlag(egress.country);
const name = countryName(egress.country, i18n.language);
return (
<Tooltip
title={egress.warp ? `Cloudflare trace · WARP ${egress.warp}` : 'Cloudflare trace'}
>
<CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
</Tooltip>
);
},
},
{
title: t('pages.inbounds.traffic'),
key: 'traffic',
@@ -308,45 +191,8 @@ export function useOutboundColumns({
);
},
},
{
title: t('pages.nodes.latency'),
key: 'testResult',
align: 'left',
width: 140,
render: (_v, record) => {
const r = testResult(outboundTestStates, record.key);
if (!r)
return isTesting(outboundTestStates, record.key) ? (
<LoadingOutlined />
) : (
<span className="empty">—</span>
);
return <TestResultPopover result={r} />;
},
},
{
title: t('check'),
key: 'test',
align: 'center',
width: 80,
render: (_v, record) => (
<Tooltip
title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}
>
<Button
type="primary"
shape="circle"
loading={isTesting(outboundTestStates, record.key)}
disabled={isUntestable(record) || isTesting(outboundTestStates, record.key)}
icon={<ThunderboltOutlined />}
aria-label={t('check')}
onClick={() => onTest(record.key, testMode)}
/>
</Tooltip>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, i18n.language, testMode, rows, outboundTestStates, outboundsTraffic, showEgressIp],
[t, rows, outboundsTraffic],
);
}
-43
View File
@@ -50,7 +50,6 @@ export const XrayConfigPayloadSchema = z
xraySetting: XraySettingsValueSchema,
inboundTags: z.array(z.string()).optional(),
clientReverseTags: z.array(z.string()).optional(),
outboundTestUrl: z.string().optional(),
// Subscription outbounds are injected at runtime (not persisted in xraySetting).
// They are provided here so the UI can display them and use their tags in
// balancers / routing rules.
@@ -67,47 +66,6 @@ export const OutboundTrafficRowSchema = z.object({
export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema);
export const OutboundTestResultSchema = z
.object({
tag: z.string().optional(),
success: z.boolean(),
delay: z.number().optional(),
error: z.string().optional(),
mode: z.string().optional(),
// HTTP-mode extras: status answered by the test URL plus the httptrace
// timing breakdown (dial to local inbound / target TLS via the outbound /
// time to first byte).
httpStatus: z.number().optional(),
connectMs: z.number().optional(),
tlsMs: z.number().optional(),
ttfbMs: z.number().optional(),
endpoints: z
.array(
z
.object({
address: z.string(),
delay: z.number().optional(),
success: z.boolean(),
error: z.string().optional(),
})
.loose(),
)
.optional(),
egress: z
.object({
ipv4: z.string().optional(),
ipv6: z.string().optional(),
country: z.string().optional(),
warp: z.string().optional(),
})
.loose()
.optional(),
})
.loose();
// Batch results from /xray/testOutbounds, aligned with the request order.
export const OutboundTestResultListSchema = z.array(OutboundTestResultSchema);
export const RuleFormSchema = z.object({
enabled: z.boolean(),
domain: z.string(),
@@ -148,4 +106,3 @@ export type RuleFormValues = z.infer<typeof RuleFormSchema>;
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
export type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
export type OutboundTrafficRow = z.infer<typeof OutboundTrafficRowSchema>;
export type OutboundTestResult = z.infer<typeof OutboundTestResultSchema>;
@@ -25,8 +25,6 @@ describe('BasicsTab malformed happyEyeballs', () => {
<BasicsTab
templateSettings={settingsWithMalformedHappyEyeballs()}
setTemplateSettings={vi.fn()}
outboundTestUrl=""
onChangeOutboundTestUrl={vi.fn()}
onResetDefault={vi.fn()}
/>,
),
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { fireEvent } from '@testing-library/react';
import { fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import OutboundsTab from '@/pages/xray/outbounds/OutboundsTab';
@@ -17,79 +17,72 @@ function settingsWithHiddenLoopback(): XraySettingsValue {
} as unknown as XraySettingsValue;
}
// The visible rows hide the balancer-loopback outbound, so every row action
// must translate the positional row index back to the outbound's real index
// in the full array. "Move to top" on the second visible row (proxy-b, real
// index 2) must move proxy-b — not the hidden loopback at positional index 1.
describe('OutboundsTab hidden-loopback index mapping', () => {
it('probes the outbound at its real array index, not the positional row index', () => {
const onTest = vi.fn();
function renderTab(isMobile: boolean) {
const setTemplateSettings = vi.fn();
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderWithProviders(
<QueryClientProvider client={queryClient}>
<OutboundsTab
templateSettings={settingsWithHiddenLoopback()}
setTemplateSettings={vi.fn()}
setTemplateSettings={setTemplateSettings}
outboundsTraffic={[]}
outboundTestStates={{}}
subscriptionTestStates={{}}
testingAll={false}
inboundTags={[]}
isMobile={false}
isMobile={isMobile}
onResetTraffic={vi.fn()}
onTest={onTest}
onTestSubscription={vi.fn()}
onTestAll={vi.fn()}
onShowWarp={vi.fn()}
onShowNord={vi.fn()}
onShowPia={vi.fn()}
/>
</QueryClientProvider>,
);
return setTemplateSettings;
}
function expectMovedProxyBToTop(setTemplateSettings: ReturnType<typeof vi.fn>) {
expect(setTemplateSettings).toHaveBeenCalled();
const updater = setTemplateSettings.mock.calls.at(-1)?.[0];
expect(typeof updater).toBe('function');
const next = updater(structuredClone(settingsWithHiddenLoopback()));
expect(next.outbounds.map((o: { tag?: string }) => o.tag)).toEqual([
'proxy-b',
'proxy-a',
'_bl_bal1',
]);
}
async function clickMoveToTop(scope: Element) {
const moreButton = scope.querySelector('button[aria-label="more"]') as HTMLButtonElement;
expect(moreButton).toBeTruthy();
fireEvent.click(moreButton);
const item = await waitFor(() => {
const found = [...document.querySelectorAll('.ant-dropdown-menu-item')].find((el) =>
el.textContent?.includes('Move to top'),
);
expect(found).toBeTruthy();
return found as HTMLElement;
});
fireEvent.click(item);
}
it('acts on the real array index from the desktop table', async () => {
const setTemplateSettings = renderTab(false);
const tbody = document.querySelector('.ant-table-tbody');
const tableRows = tbody?.querySelectorAll('tr.ant-table-row') ?? [];
expect(tableRows.length).toBe(2);
const checkButton = tableRows[1].querySelector(
'button[aria-label="Check"]',
) as HTMLButtonElement;
fireEvent.click(checkButton);
expect(onTest).toHaveBeenCalledTimes(1);
expect(onTest.mock.calls[0][0]).toBe(2);
await clickMoveToTop(tableRows[1]);
expectMovedProxyBToTop(setTemplateSettings);
});
it('probes the real array index from the mobile card list as well', () => {
const onTest = vi.fn();
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderWithProviders(
<QueryClientProvider client={queryClient}>
<OutboundsTab
templateSettings={settingsWithHiddenLoopback()}
setTemplateSettings={vi.fn()}
outboundsTraffic={[]}
outboundTestStates={{}}
subscriptionTestStates={{}}
testingAll={false}
inboundTags={[]}
isMobile
onResetTraffic={vi.fn()}
onTest={onTest}
onTestSubscription={vi.fn()}
onTestAll={vi.fn()}
onShowWarp={vi.fn()}
onShowNord={vi.fn()}
onShowPia={vi.fn()}
/>
</QueryClientProvider>,
);
it('acts on the real array index from the mobile card list as well', async () => {
const setTemplateSettings = renderTab(true);
const cards = document.querySelectorAll('.outbound-card');
expect(cards.length).toBe(2);
const checkButton = cards[1].querySelector('button[aria-label="Check"]') as HTMLButtonElement;
fireEvent.click(checkButton);
expect(onTest).toHaveBeenCalledTimes(1);
expect(onTest.mock.calls[0][0]).toBe(2);
await clickMoveToTop(cards[1]);
expectMovedProxyBToTop(setTemplateSettings);
});
});
@@ -12,7 +12,6 @@ function xrayPayload(overrides: Record<string, unknown> = {}) {
xraySetting: {},
inboundTags: [],
clientReverseTags: [],
outboundTestUrl: 'https://test.example',
subscriptionOutbounds: [],
subscriptionOutboundTags: [],
...overrides,
@@ -48,23 +47,4 @@ describe('useXraySetting', () => {
await waitFor(() => expect(result.current.subscriptionOutbounds).toEqual([{ tag: 'after' }]));
expect(result.current.xraySetting).toBe('{"outbounds":[]}');
});
it('keeps the outbound test URL input empty when it is cleared', async () => {
const payload = xrayPayload({ outboundTestUrl: 'https://www.google.com/generate_204' });
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
return new Msg(true, '');
});
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useXraySetting(), { wrapper });
await waitFor(() => expect(result.current.fetched).toBe(true));
act(() => result.current.setOutboundTestUrl(''));
expect(result.current.outboundTestUrl).toBe('');
expect(result.current.saveDisabled).toBe(true);
});
});