diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 9ca62ec9e..121614e1c 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -27,23 +27,18 @@ }, "schemas": { "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": { @@ -71,11 +66,9 @@ "type": "integer" }, "ldapEnable": { - "description": "LDAP settings", "type": "boolean" }, "ldapFlagField": { - "description": "Generic flag configuration", "type": "string" }, "ldapHost": { @@ -108,7 +101,6 @@ "type": "boolean" }, "ldapUserAttr": { - "description": "e.g., mail or uid", "type": "string" }, "ldapUserFilter": { @@ -118,299 +110,231 @@ "type": "string" }, "pageSize": { - "description": "UI settings\nNumber of items per page in lists (0 disables pagination)", "maximum": 1000, "minimum": 0, "type": "integer" }, "panelOutbound": { - "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)", "type": "string" }, "remarkTemplate": { - "description": "Subscription remark template ({{VAR}} tokens) rendered per client", "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" }, "smtpCpu": { - "description": "CPU threshold for email notifications", "maximum": 100, "minimum": 0, "type": "integer" }, "smtpEnable": { - "description": "Email (SMTP) notification settings\nEnable email notifications", "type": "boolean" }, "smtpEnabledEvents": { - "description": "Comma-separated event types to send via email", "type": "string" }, "smtpEncryptionType": { - "description": "SMTP encryption: none, starttls, tls", "type": "string" }, "smtpHost": { - "description": "SMTP server host", "type": "string" }, "smtpMemory": { - "description": "Memory threshold for email notifications", "maximum": 100, "minimum": 0, "type": "integer" }, "smtpPassword": { - "description": "SMTP password", "type": "string" }, "smtpPort": { - "description": "SMTP server port", "maximum": 65535, "minimum": 1, "type": "integer" }, "smtpTo": { - "description": "Comma-separated recipient emails", "type": "string" }, "smtpUsername": { - "description": "SMTP username", "type": "string" }, "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" }, "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" }, "subHideSettings": { - "description": "Hide server settings in happ subscription (Only for Happ)", "type": "boolean" }, "subIncyEnableRouting": { - "description": "Enable routing injection for the Incy client", "type": "boolean" }, "subIncyRoutingRules": { - "description": "Incy routing deep-link injected into the subscription body (Only for Incy)", "type": "string" }, "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" }, "subSupportUrl": { - "description": "Subscription support URL", "type": "string" }, "subThemeDir": { - "description": "Absolute path to a folder containing a custom subscription page template", "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" }, "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" }, "tgEnabledEvents": { - "description": "Comma-separated event types to send via Telegram", "type": "string" }, "tgLang": { - "description": "Telegram bot language", "type": "string" }, "tgMemory": { - "description": "Memory usage threshold for alerts (percent)", "maximum": 100, "minimum": 0, "type": "integer" }, "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" }, "warpUpdateInterval": { - "description": "WARP", "minimum": 0, "type": "integer" }, "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" @@ -515,23 +439,18 @@ "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": { @@ -580,11 +499,9 @@ "type": "integer" }, "ldapEnable": { - "description": "LDAP settings", "type": "boolean" }, "ldapFlagField": { - "description": "Generic flag configuration", "type": "string" }, "ldapHost": { @@ -617,7 +534,6 @@ "type": "boolean" }, "ldapUserAttr": { - "description": "e.g., mail or uid", "type": "string" }, "ldapUserFilter": { @@ -627,299 +543,231 @@ "type": "string" }, "pageSize": { - "description": "UI settings\nNumber of items per page in lists (0 disables pagination)", "maximum": 1000, "minimum": 0, "type": "integer" }, "panelOutbound": { - "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)", "type": "string" }, "remarkTemplate": { - "description": "Subscription remark template ({{VAR}} tokens) rendered per client", "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" }, "smtpCpu": { - "description": "CPU threshold for email notifications", "maximum": 100, "minimum": 0, "type": "integer" }, "smtpEnable": { - "description": "Email (SMTP) notification settings\nEnable email notifications", "type": "boolean" }, "smtpEnabledEvents": { - "description": "Comma-separated event types to send via email", "type": "string" }, "smtpEncryptionType": { - "description": "SMTP encryption: none, starttls, tls", "type": "string" }, "smtpHost": { - "description": "SMTP server host", "type": "string" }, "smtpMemory": { - "description": "Memory threshold for email notifications", "maximum": 100, "minimum": 0, "type": "integer" }, "smtpPassword": { - "description": "SMTP password", "type": "string" }, "smtpPort": { - "description": "SMTP server port", "maximum": 65535, "minimum": 1, "type": "integer" }, "smtpTo": { - "description": "Comma-separated recipient emails", "type": "string" }, "smtpUsername": { - "description": "SMTP username", "type": "string" }, "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" }, "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" }, "subHideSettings": { - "description": "Hide server settings in happ subscription (Only for Happ)", "type": "boolean" }, "subIncyEnableRouting": { - "description": "Enable routing injection for the Incy client", "type": "boolean" }, "subIncyRoutingRules": { - "description": "Incy routing deep-link injected into the subscription body (Only for Incy)", "type": "string" }, "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" }, "subSupportUrl": { - "description": "Subscription support URL", "type": "string" }, "subThemeDir": { - "description": "Absolute path to a folder containing a custom subscription page template", "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" }, "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" }, "tgEnabledEvents": { - "description": "Comma-separated event types to send via Telegram", "type": "string" }, "tgLang": { - "description": "Telegram bot language", "type": "string" }, "tgMemory": { - "description": "Memory usage threshold for alerts (percent)", "maximum": 100, "minimum": 0, "type": "integer" }, "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" }, "warpUpdateInterval": { - "description": "WARP", "minimum": 0, "type": "integer" }, "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" @@ -1453,7 +1301,6 @@ "type": "object" }, "Host": { - "description": "Host is an override endpoint attached to an inbound: at subscription time each\nenabled host renders one share link/proxy with its own address/port/TLS/etc.,\nsuperseding the legacy externalProxy array. Free-JSON fields are stored as\ntext and parsed in the sub layer; slice fields use the json serializer.", "properties": { "address": { "example": "cdn.example.com", @@ -1487,6 +1334,9 @@ "fingerprint": { "type": "string" }, + "groupId": { + "type": "string" + }, "hostHeader": { "type": "string" }, @@ -1601,6 +1451,7 @@ "excludeFromSubTypes", "finalMask", "fingerprint", + "groupId", "hostHeader", "id", "inboundId", @@ -1628,6 +1479,175 @@ ], "type": "object" }, + "HostGroup": { + "properties": { + "allowInsecure": { + "type": "boolean" + }, + "alpn": { + "items": { + "type": "string" + }, + "type": "array" + }, + "echConfigList": { + "type": "string" + }, + "excludeFromSubTypes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "finalMask": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "hostHeader": { + "type": "string" + }, + "hosts": { + "items": { + "type": "string" + }, + "type": "array" + }, + "inboundIds": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "isDisabled": { + "type": "boolean" + }, + "isHidden": { + "type": "boolean" + }, + "keepSniBlank": { + "type": "boolean" + }, + "mihomoIpVersion": { + "enum": [ + "dual", + "ipv4", + "ipv6", + "ipv4-prefer", + "ipv6-prefer" + ], + "type": "string" + }, + "mihomoX25519": { + "type": "boolean" + }, + "muxParams": { + "type": "string" + }, + "nodeGuids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "overrideSniFromAddress": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "pinnedPeerCertSha256": { + "items": { + "type": "string" + }, + "type": "array" + }, + "port": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "remark": { + "maxLength": 256, + "type": "string" + }, + "security": { + "enum": [ + "same", + "tls", + "none", + "reality" + ], + "type": "string" + }, + "serverDescription": { + "maxLength": 64, + "type": "string" + }, + "shuffleHost": { + "type": "boolean" + }, + "sni": { + "type": "string" + }, + "sockoptParams": { + "type": "string" + }, + "sortOrder": { + "type": "integer" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array" + }, + "verifyPeerCertByName": { + "type": "string" + }, + "vlessRoute": { + "type": "string" + } + }, + "required": [ + "allowInsecure", + "alpn", + "echConfigList", + "excludeFromSubTypes", + "finalMask", + "fingerprint", + "groupId", + "hostHeader", + "hosts", + "inboundIds", + "isDisabled", + "isHidden", + "keepSniBlank", + "mihomoIpVersion", + "mihomoX25519", + "muxParams", + "nodeGuids", + "overrideSniFromAddress", + "path", + "pinnedPeerCertSha256", + "port", + "remark", + "security", + "serverDescription", + "shuffleHost", + "sni", + "sockoptParams", + "sortOrder", + "tags", + "verifyPeerCertByName", + "vlessRoute" + ], + "type": "object" + }, "Inbound": { "description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.", "properties": { @@ -1915,17 +1935,12 @@ "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" - }, + "obj": {}, "success": { - "description": "Indicates if the operation was successful", "type": "boolean" } }, @@ -8198,7 +8213,7 @@ "obj": { "type": "array", "items": { - "$ref": "#/components/schemas/Host" + "$ref": "#/components/schemas/HostGroup" } } } @@ -8207,27 +8222,30 @@ "success": true, "obj": [ { - "address": "cdn.example.com", "allowInsecure": false, "alpn": [ "" ], - "createdAt": 0, "echConfigList": "", "excludeFromSubTypes": [ "" ], "finalMask": "", "fingerprint": "", + "groupId": "", "hostHeader": "", - "id": 1, - "inboundId": 1, + "hosts": [ + "" + ], + "inboundIds": [ + 0 + ], "isDisabled": false, "isHidden": false, "keepSniBlank": false, "mihomoIpVersion": "dual", "mihomoX25519": false, - "muxParams": null, + "muxParams": "", "nodeGuids": [ "" ], @@ -8236,20 +8254,19 @@ "pinnedPeerCertSha256": [ "" ], - "port": 8443, - "remark": "cdn-front", + "port": 0, + "remark": "", "security": "same", "serverDescription": "", "shuffleHost": false, "sni": "", - "sockoptParams": null, + "sockoptParams": "", "sortOrder": 0, "tags": [ "" ], - "updatedAt": 0, "verifyPeerCertByName": "", - "vlessRoute": "443" + "vlessRoute": "" } ] } @@ -8259,21 +8276,21 @@ } } }, - "/panel/api/hosts/get/{id}": { + "/panel/api/hosts/get/{groupId}": { "get": { "tags": [ "Hosts" ], - "summary": "Fetch a single host by ID.", - "operationId": "get_panel_api_hosts_get_id", + "summary": "Fetch a single host group by Group ID.", + "operationId": "get_panel_api_hosts_get_groupId", "parameters": [ { - "name": "id", + "name": "groupId", "in": "path", "required": true, - "description": "Host ID.", + "description": "Host Group ID.", "schema": { - "type": "integer" + "type": "string" } } ], @@ -8292,34 +8309,37 @@ "type": "string" }, "obj": { - "$ref": "#/components/schemas/Host" + "$ref": "#/components/schemas/HostGroup" } } }, "example": { "success": true, "obj": { - "address": "cdn.example.com", "allowInsecure": false, "alpn": [ "" ], - "createdAt": 0, "echConfigList": "", "excludeFromSubTypes": [ "" ], "finalMask": "", "fingerprint": "", + "groupId": "", "hostHeader": "", - "id": 1, - "inboundId": 1, + "hosts": [ + "" + ], + "inboundIds": [ + 0 + ], "isDisabled": false, "isHidden": false, "keepSniBlank": false, "mihomoIpVersion": "dual", "mihomoX25519": false, - "muxParams": null, + "muxParams": "", "nodeGuids": [ "" ], @@ -8328,20 +8348,19 @@ "pinnedPeerCertSha256": [ "" ], - "port": 8443, - "remark": "cdn-front", + "port": 0, + "remark": "", "security": "same", "serverDescription": "", "shuffleHost": false, "sni": "", - "sockoptParams": null, + "sockoptParams": "", "sortOrder": 0, "tags": [ "" ], - "updatedAt": 0, "verifyPeerCertByName": "", - "vlessRoute": "443" + "vlessRoute": "" } } } @@ -8355,7 +8374,7 @@ "tags": [ "Hosts" ], - "summary": "Fetch one inbound's hosts, ordered by sort order then id.", + "summary": "Fetch one inbound's hosts, grouped by host group.", "operationId": "get_panel_api_hosts_byInbound_inboundId", "parameters": [ { @@ -8385,7 +8404,7 @@ "obj": { "type": "array", "items": { - "$ref": "#/components/schemas/Host" + "$ref": "#/components/schemas/HostGroup" } } } @@ -8394,27 +8413,30 @@ "success": true, "obj": [ { - "address": "cdn.example.com", "allowInsecure": false, "alpn": [ "" ], - "createdAt": 0, "echConfigList": "", "excludeFromSubTypes": [ "" ], "finalMask": "", "fingerprint": "", + "groupId": "", "hostHeader": "", - "id": 1, - "inboundId": 1, + "hosts": [ + "" + ], + "inboundIds": [ + 0 + ], "isDisabled": false, "isHidden": false, "keepSniBlank": false, "mihomoIpVersion": "dual", "mihomoX25519": false, - "muxParams": null, + "muxParams": "", "nodeGuids": [ "" ], @@ -8423,20 +8445,19 @@ "pinnedPeerCertSha256": [ "" ], - "port": 8443, - "remark": "cdn-front", + "port": 0, + "remark": "", "security": "same", "serverDescription": "", "shuffleHost": false, "sni": "", - "sockoptParams": null, + "sockoptParams": "", "sortOrder": 0, "tags": [ "" ], - "updatedAt": 0, "verifyPeerCertByName": "", - "vlessRoute": "443" + "vlessRoute": "" } ] } @@ -8489,7 +8510,7 @@ "tags": [ "Hosts" ], - "summary": "Create a host on an inbound. inboundId and remark are required; security defaults to \"same\" (inherit the inbound).", + "summary": "Create a host group on inbounds.", "operationId": "post_panel_api_hosts_add", "requestBody": { "required": true, @@ -8499,12 +8520,15 @@ "type": "object" }, "example": { - "inboundId": 1, + "inboundIds": [ + 1 + ], "remark": "cdn-front", - "address": "cdn.example.com", + "hosts": [ + "cdn.example.com" + ], "port": 8443, "security": "same", - "sni": "", "tags": [ "CDN" ] @@ -8527,57 +8551,63 @@ "type": "string" }, "obj": { - "$ref": "#/components/schemas/Host" + "type": "array", + "items": { + "$ref": "#/components/schemas/Host" + } } } }, "example": { "success": true, - "obj": { - "address": "cdn.example.com", - "allowInsecure": false, - "alpn": [ - "" - ], - "createdAt": 0, - "echConfigList": "", - "excludeFromSubTypes": [ - "" - ], - "finalMask": "", - "fingerprint": "", - "hostHeader": "", - "id": 1, - "inboundId": 1, - "isDisabled": false, - "isHidden": false, - "keepSniBlank": false, - "mihomoIpVersion": "dual", - "mihomoX25519": false, - "muxParams": null, - "nodeGuids": [ - "" - ], - "overrideSniFromAddress": false, - "path": "", - "pinnedPeerCertSha256": [ - "" - ], - "port": 8443, - "remark": "cdn-front", - "security": "same", - "serverDescription": "", - "shuffleHost": false, - "sni": "", - "sockoptParams": null, - "sortOrder": 0, - "tags": [ - "" - ], - "updatedAt": 0, - "verifyPeerCertByName": "", - "vlessRoute": "443" - } + "obj": [ + { + "address": "cdn.example.com", + "allowInsecure": false, + "alpn": [ + "" + ], + "createdAt": 0, + "echConfigList": "", + "excludeFromSubTypes": [ + "" + ], + "finalMask": "", + "fingerprint": "", + "groupId": "", + "hostHeader": "", + "id": 1, + "inboundId": 1, + "isDisabled": false, + "isHidden": false, + "keepSniBlank": false, + "mihomoIpVersion": "dual", + "mihomoX25519": false, + "muxParams": null, + "nodeGuids": [ + "" + ], + "overrideSniFromAddress": false, + "path": "", + "pinnedPeerCertSha256": [ + "" + ], + "port": 8443, + "remark": "cdn-front", + "security": "same", + "serverDescription": "", + "shuffleHost": false, + "sni": "", + "sockoptParams": null, + "sortOrder": 0, + "tags": [ + "" + ], + "updatedAt": 0, + "verifyPeerCertByName": "", + "vlessRoute": "443" + } + ] } } } @@ -8585,21 +8615,21 @@ } } }, - "/panel/api/hosts/update/{id}": { + "/panel/api/hosts/update/{groupId}": { "post": { "tags": [ "Hosts" ], - "summary": "Replace a host’s content. The inbound and sort order are immutable here (use /reorder for ordering).", - "operationId": "post_panel_api_hosts_update_id", + "summary": "Replace a host group’s content.", + "operationId": "post_panel_api_hosts_update_groupId", "parameters": [ { - "name": "id", + "name": "groupId", "in": "path", "required": true, - "description": "Host ID.", + "description": "Host Group ID.", "schema": { - "type": "integer" + "type": "string" } } ], @@ -8611,12 +8641,15 @@ "type": "object" }, "example": { - "inboundId": 1, + "inboundIds": [ + 1 + ], "remark": "cdn-front", - "address": "cdn.example.com", + "hosts": [ + "cdn.example.com" + ], "port": 8443, "security": "same", - "sni": "", "tags": [ "CDN" ] @@ -8639,57 +8672,63 @@ "type": "string" }, "obj": { - "$ref": "#/components/schemas/Host" + "type": "array", + "items": { + "$ref": "#/components/schemas/Host" + } } } }, "example": { "success": true, - "obj": { - "address": "cdn.example.com", - "allowInsecure": false, - "alpn": [ - "" - ], - "createdAt": 0, - "echConfigList": "", - "excludeFromSubTypes": [ - "" - ], - "finalMask": "", - "fingerprint": "", - "hostHeader": "", - "id": 1, - "inboundId": 1, - "isDisabled": false, - "isHidden": false, - "keepSniBlank": false, - "mihomoIpVersion": "dual", - "mihomoX25519": false, - "muxParams": null, - "nodeGuids": [ - "" - ], - "overrideSniFromAddress": false, - "path": "", - "pinnedPeerCertSha256": [ - "" - ], - "port": 8443, - "remark": "cdn-front", - "security": "same", - "serverDescription": "", - "shuffleHost": false, - "sni": "", - "sockoptParams": null, - "sortOrder": 0, - "tags": [ - "" - ], - "updatedAt": 0, - "verifyPeerCertByName": "", - "vlessRoute": "443" - } + "obj": [ + { + "address": "cdn.example.com", + "allowInsecure": false, + "alpn": [ + "" + ], + "createdAt": 0, + "echConfigList": "", + "excludeFromSubTypes": [ + "" + ], + "finalMask": "", + "fingerprint": "", + "groupId": "", + "hostHeader": "", + "id": 1, + "inboundId": 1, + "isDisabled": false, + "isHidden": false, + "keepSniBlank": false, + "mihomoIpVersion": "dual", + "mihomoX25519": false, + "muxParams": null, + "nodeGuids": [ + "" + ], + "overrideSniFromAddress": false, + "path": "", + "pinnedPeerCertSha256": [ + "" + ], + "port": 8443, + "remark": "cdn-front", + "security": "same", + "serverDescription": "", + "shuffleHost": false, + "sni": "", + "sockoptParams": null, + "sortOrder": 0, + "tags": [ + "" + ], + "updatedAt": 0, + "verifyPeerCertByName": "", + "vlessRoute": "443" + } + ] } } } @@ -8697,21 +8736,21 @@ } } }, - "/panel/api/hosts/del/{id}": { + "/panel/api/hosts/del/{groupId}": { "post": { "tags": [ "Hosts" ], - "summary": "Delete a host.", - "operationId": "post_panel_api_hosts_del_id", + "summary": "Delete a host group.", + "operationId": "post_panel_api_hosts_del_groupId", "parameters": [ { - "name": "id", + "name": "groupId", "in": "path", "required": true, - "description": "Host ID.", + "description": "Host Group ID.", "schema": { - "type": "integer" + "type": "string" } } ], @@ -8738,21 +8777,21 @@ } } }, - "/panel/api/hosts/setEnable/{id}": { + "/panel/api/hosts/setEnable/{groupId}": { "post": { "tags": [ "Hosts" ], - "summary": "Enable or disable a single host (disabled hosts are skipped in subscriptions).", - "operationId": "post_panel_api_hosts_setEnable_id", + "summary": "Enable or disable a host group.", + "operationId": "post_panel_api_hosts_setEnable_groupId", "parameters": [ { - "name": "id", + "name": "groupId", "in": "path", "required": true, - "description": "Host ID.", + "description": "Host Group ID.", "schema": { - "type": "integer" + "type": "string" } } ], @@ -8797,7 +8836,7 @@ "tags": [ "Hosts" ], - "summary": "Set host sort order by the position of each id in the array.", + "summary": "Set host group sort order by the position of each groupId in the array.", "operationId": "post_panel_api_hosts_reorder", "requestBody": { "required": true, @@ -8808,9 +8847,8 @@ }, "example": { "ids": [ - 3, - 1, - 2 + "abc-123", + "def-456" ] } } @@ -8839,12 +8877,122 @@ } } }, + "/panel/api/hosts/bulk/add": { + "post": { + "tags": [ + "Hosts" + ], + "summary": "Add a host group to inbounds (same as /add).", + "operationId": "post_panel_api_hosts_bulk_add", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "inboundIds": [ + 1, + 2 + ], + "hosts": [ + "cdn.example.com", + "cdn2.example.com:443" + ], + "remark": "Cloudflare CDN", + "port": 0, + "security": "same", + "isDisabled": false + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Host" + } + } + } + }, + "example": { + "success": true, + "obj": [ + { + "address": "cdn.example.com", + "allowInsecure": false, + "alpn": [ + "" + ], + "createdAt": 0, + "echConfigList": "", + "excludeFromSubTypes": [ + "" + ], + "finalMask": "", + "fingerprint": "", + "groupId": "", + "hostHeader": "", + "id": 1, + "inboundId": 1, + "isDisabled": false, + "isHidden": false, + "keepSniBlank": false, + "mihomoIpVersion": "dual", + "mihomoX25519": false, + "muxParams": null, + "nodeGuids": [ + "" + ], + "overrideSniFromAddress": false, + "path": "", + "pinnedPeerCertSha256": [ + "" + ], + "port": 8443, + "remark": "cdn-front", + "security": "same", + "serverDescription": "", + "shuffleHost": false, + "sni": "", + "sockoptParams": null, + "sortOrder": 0, + "tags": [ + "" + ], + "updatedAt": 0, + "verifyPeerCertByName": "", + "vlessRoute": "443" + } + ] + } + } + } + } + } + } + }, "/panel/api/hosts/bulk/setEnable": { "post": { "tags": [ "Hosts" ], - "summary": "Enable or disable many hosts in one call.", + "summary": "Enable or disable many host groups in one call.", "operationId": "post_panel_api_hosts_bulk_setEnable", "requestBody": { "required": true, @@ -8855,9 +9003,8 @@ }, "example": { "ids": [ - 1, - 2, - 3 + "abc-123", + "def-456" ], "enable": false } @@ -8892,7 +9039,7 @@ "tags": [ "Hosts" ], - "summary": "Delete many hosts in one call.", + "summary": "Delete many host groups in one call.", "operationId": "post_panel_api_hosts_bulk_del", "requestBody": { "required": true, @@ -8903,9 +9050,8 @@ }, "example": { "ids": [ - 1, - 2, - 3 + "abc-123", + "def-456" ] } } diff --git a/frontend/src/api/queries/useHostMutations.ts b/frontend/src/api/queries/useHostMutations.ts index 5892b4084..8f5299188 100644 --- a/frontend/src/api/queries/useHostMutations.ts +++ b/frontend/src/api/queries/useHostMutations.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { HttpUtil } from '@/utils'; import { keys } from '@/api/queryKeys'; -import type { HostFormValues } from '@/schemas/api/host'; +import type { BulkAddHostValues } from '@/schemas/api/host'; const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } }; @@ -10,51 +10,51 @@ export function useHostMutations() { const queryClient = useQueryClient(); const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.hosts.root() }); - const createMut = useMutation({ - mutationFn: (payload: Partial) => HttpUtil.post('/panel/api/hosts/add', payload), + const bulkCreateMut = useMutation({ + mutationFn: (payload: BulkAddHostValues) => HttpUtil.post('/panel/api/hosts/bulk/add', payload, JSON_HEADERS), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const updateMut = useMutation({ - mutationFn: ({ id, payload }: { id: number; payload: Partial }) => - HttpUtil.post(`/panel/api/hosts/update/${id}`, payload), + mutationFn: ({ groupId, payload }: { groupId: string; payload: BulkAddHostValues }) => + HttpUtil.post(`/panel/api/hosts/update/${groupId}`, payload, JSON_HEADERS), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const removeMut = useMutation({ - mutationFn: (id: number) => HttpUtil.post(`/panel/api/hosts/del/${id}`), + mutationFn: (groupId: string) => HttpUtil.post(`/panel/api/hosts/del/${groupId}`), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const setEnableMut = useMutation({ - mutationFn: ({ id, enable }: { id: number; enable: boolean }) => - HttpUtil.post(`/panel/api/hosts/setEnable/${id}`, { enable }), + mutationFn: ({ groupId, enable }: { groupId: string; enable: boolean }) => + HttpUtil.post(`/panel/api/hosts/setEnable/${groupId}`, { enable }), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const reorderMut = useMutation({ - mutationFn: (ids: number[]) => HttpUtil.post('/panel/api/hosts/reorder', { ids }, JSON_HEADERS), + mutationFn: (groupIds: string[]) => HttpUtil.post('/panel/api/hosts/reorder', { ids: groupIds }, JSON_HEADERS), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const bulkEnableMut = useMutation({ - mutationFn: ({ ids, enable }: { ids: number[]; enable: boolean }) => - HttpUtil.post('/panel/api/hosts/bulk/setEnable', { ids, enable }, JSON_HEADERS), + mutationFn: ({ groupIds, enable }: { groupIds: string[]; enable: boolean }) => + HttpUtil.post('/panel/api/hosts/bulk/setEnable', { ids: groupIds, enable }, JSON_HEADERS), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const bulkDelMut = useMutation({ - mutationFn: (ids: number[]) => HttpUtil.post('/panel/api/hosts/bulk/del', { ids }, JSON_HEADERS), + mutationFn: (groupIds: string[]) => HttpUtil.post('/panel/api/hosts/bulk/del', { ids: groupIds }, JSON_HEADERS), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); return { - create: (payload: Partial) => createMut.mutateAsync(payload), - update: (id: number, payload: Partial) => updateMut.mutateAsync({ id, payload }), - remove: (id: number) => removeMut.mutateAsync(id), - setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }), - reorder: (ids: number[]) => reorderMut.mutateAsync(ids), - bulkSetEnable: (ids: number[], enable: boolean) => bulkEnableMut.mutateAsync({ ids, enable }), - bulkDel: (ids: number[]) => bulkDelMut.mutateAsync(ids), + bulkCreate: (payload: BulkAddHostValues) => bulkCreateMut.mutateAsync(payload), + update: (groupId: string, payload: BulkAddHostValues) => updateMut.mutateAsync({ groupId, payload }), + remove: (groupId: string) => removeMut.mutateAsync(groupId), + setEnable: (groupId: string, enable: boolean) => setEnableMut.mutateAsync({ groupId, enable }), + reorder: (groupIds: string[]) => reorderMut.mutateAsync(groupIds), + bulkSetEnable: (groupIds: string[], enable: boolean) => bulkEnableMut.mutateAsync({ groupIds, enable }), + bulkDel: (groupIds: string[]) => bulkDelMut.mutateAsync(groupIds), }; } diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 1d48a2348..57b2524c2 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -314,6 +314,7 @@ export const EXAMPLES: Record = { ], "finalMask": "", "fingerprint": "", + "groupId": "", "hostHeader": "", "id": 1, "inboundId": 1, @@ -346,6 +347,53 @@ export const EXAMPLES: Record = { "verifyPeerCertByName": "", "vlessRoute": "443" }, + "HostGroup": { + "allowInsecure": false, + "alpn": [ + "" + ], + "echConfigList": "", + "excludeFromSubTypes": [ + "" + ], + "finalMask": "", + "fingerprint": "", + "groupId": "", + "hostHeader": "", + "hosts": [ + "" + ], + "inboundIds": [ + 0 + ], + "isDisabled": false, + "isHidden": false, + "keepSniBlank": false, + "mihomoIpVersion": "dual", + "mihomoX25519": false, + "muxParams": "", + "nodeGuids": [ + "" + ], + "overrideSniFromAddress": false, + "path": "", + "pinnedPeerCertSha256": [ + "" + ], + "port": 0, + "remark": "", + "security": "same", + "serverDescription": "", + "shuffleHost": false, + "sni": "", + "sockoptParams": "", + "sortOrder": 0, + "tags": [ + "" + ], + "verifyPeerCertByName": "", + "vlessRoute": "" + }, "Inbound": { "clientStats": [ { diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index e644df455..df0abbfc6 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1,23 +1,18 @@ // Code generated by tools/openapigen. DO NOT EDIT. export const SCHEMAS: Record = { "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": { @@ -45,11 +40,9 @@ export const SCHEMAS: Record = { "type": "integer" }, "ldapEnable": { - "description": "LDAP settings", "type": "boolean" }, "ldapFlagField": { - "description": "Generic flag configuration", "type": "string" }, "ldapHost": { @@ -82,7 +75,6 @@ export const SCHEMAS: Record = { "type": "boolean" }, "ldapUserAttr": { - "description": "e.g., mail or uid", "type": "string" }, "ldapUserFilter": { @@ -92,299 +84,231 @@ export const SCHEMAS: Record = { "type": "string" }, "pageSize": { - "description": "UI settings\nNumber of items per page in lists (0 disables pagination)", "maximum": 1000, "minimum": 0, "type": "integer" }, "panelOutbound": { - "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)", "type": "string" }, "remarkTemplate": { - "description": "Subscription remark template ({{VAR}} tokens) rendered per client", "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" }, "smtpCpu": { - "description": "CPU threshold for email notifications", "maximum": 100, "minimum": 0, "type": "integer" }, "smtpEnable": { - "description": "Email (SMTP) notification settings\nEnable email notifications", "type": "boolean" }, "smtpEnabledEvents": { - "description": "Comma-separated event types to send via email", "type": "string" }, "smtpEncryptionType": { - "description": "SMTP encryption: none, starttls, tls", "type": "string" }, "smtpHost": { - "description": "SMTP server host", "type": "string" }, "smtpMemory": { - "description": "Memory threshold for email notifications", "maximum": 100, "minimum": 0, "type": "integer" }, "smtpPassword": { - "description": "SMTP password", "type": "string" }, "smtpPort": { - "description": "SMTP server port", "maximum": 65535, "minimum": 1, "type": "integer" }, "smtpTo": { - "description": "Comma-separated recipient emails", "type": "string" }, "smtpUsername": { - "description": "SMTP username", "type": "string" }, "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" }, "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" }, "subHideSettings": { - "description": "Hide server settings in happ subscription (Only for Happ)", "type": "boolean" }, "subIncyEnableRouting": { - "description": "Enable routing injection for the Incy client", "type": "boolean" }, "subIncyRoutingRules": { - "description": "Incy routing deep-link injected into the subscription body (Only for Incy)", "type": "string" }, "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" }, "subSupportUrl": { - "description": "Subscription support URL", "type": "string" }, "subThemeDir": { - "description": "Absolute path to a folder containing a custom subscription page template", "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" }, "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" }, "tgEnabledEvents": { - "description": "Comma-separated event types to send via Telegram", "type": "string" }, "tgLang": { - "description": "Telegram bot language", "type": "string" }, "tgMemory": { - "description": "Memory usage threshold for alerts (percent)", "maximum": 100, "minimum": 0, "type": "integer" }, "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" }, "warpUpdateInterval": { - "description": "WARP", "minimum": 0, "type": "integer" }, "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" @@ -489,23 +413,18 @@ export const SCHEMAS: Record = { "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": { @@ -554,11 +473,9 @@ export const SCHEMAS: Record = { "type": "integer" }, "ldapEnable": { - "description": "LDAP settings", "type": "boolean" }, "ldapFlagField": { - "description": "Generic flag configuration", "type": "string" }, "ldapHost": { @@ -591,7 +508,6 @@ export const SCHEMAS: Record = { "type": "boolean" }, "ldapUserAttr": { - "description": "e.g., mail or uid", "type": "string" }, "ldapUserFilter": { @@ -601,299 +517,231 @@ export const SCHEMAS: Record = { "type": "string" }, "pageSize": { - "description": "UI settings\nNumber of items per page in lists (0 disables pagination)", "maximum": 1000, "minimum": 0, "type": "integer" }, "panelOutbound": { - "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)", "type": "string" }, "remarkTemplate": { - "description": "Subscription remark template ({{VAR}} tokens) rendered per client", "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" }, "smtpCpu": { - "description": "CPU threshold for email notifications", "maximum": 100, "minimum": 0, "type": "integer" }, "smtpEnable": { - "description": "Email (SMTP) notification settings\nEnable email notifications", "type": "boolean" }, "smtpEnabledEvents": { - "description": "Comma-separated event types to send via email", "type": "string" }, "smtpEncryptionType": { - "description": "SMTP encryption: none, starttls, tls", "type": "string" }, "smtpHost": { - "description": "SMTP server host", "type": "string" }, "smtpMemory": { - "description": "Memory threshold for email notifications", "maximum": 100, "minimum": 0, "type": "integer" }, "smtpPassword": { - "description": "SMTP password", "type": "string" }, "smtpPort": { - "description": "SMTP server port", "maximum": 65535, "minimum": 1, "type": "integer" }, "smtpTo": { - "description": "Comma-separated recipient emails", "type": "string" }, "smtpUsername": { - "description": "SMTP username", "type": "string" }, "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" }, "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" }, "subHideSettings": { - "description": "Hide server settings in happ subscription (Only for Happ)", "type": "boolean" }, "subIncyEnableRouting": { - "description": "Enable routing injection for the Incy client", "type": "boolean" }, "subIncyRoutingRules": { - "description": "Incy routing deep-link injected into the subscription body (Only for Incy)", "type": "string" }, "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" }, "subSupportUrl": { - "description": "Subscription support URL", "type": "string" }, "subThemeDir": { - "description": "Absolute path to a folder containing a custom subscription page template", "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" }, "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" }, "tgEnabledEvents": { - "description": "Comma-separated event types to send via Telegram", "type": "string" }, "tgLang": { - "description": "Telegram bot language", "type": "string" }, "tgMemory": { - "description": "Memory usage threshold for alerts (percent)", "maximum": 100, "minimum": 0, "type": "integer" }, "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" }, "warpUpdateInterval": { - "description": "WARP", "minimum": 0, "type": "integer" }, "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" @@ -1427,7 +1275,6 @@ export const SCHEMAS: Record = { "type": "object" }, "Host": { - "description": "Host is an override endpoint attached to an inbound: at subscription time each\nenabled host renders one share link/proxy with its own address/port/TLS/etc.,\nsuperseding the legacy externalProxy array. Free-JSON fields are stored as\ntext and parsed in the sub layer; slice fields use the json serializer.", "properties": { "address": { "example": "cdn.example.com", @@ -1461,6 +1308,9 @@ export const SCHEMAS: Record = { "fingerprint": { "type": "string" }, + "groupId": { + "type": "string" + }, "hostHeader": { "type": "string" }, @@ -1575,6 +1425,7 @@ export const SCHEMAS: Record = { "excludeFromSubTypes", "finalMask", "fingerprint", + "groupId", "hostHeader", "id", "inboundId", @@ -1602,6 +1453,175 @@ export const SCHEMAS: Record = { ], "type": "object" }, + "HostGroup": { + "properties": { + "allowInsecure": { + "type": "boolean" + }, + "alpn": { + "items": { + "type": "string" + }, + "type": "array" + }, + "echConfigList": { + "type": "string" + }, + "excludeFromSubTypes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "finalMask": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "hostHeader": { + "type": "string" + }, + "hosts": { + "items": { + "type": "string" + }, + "type": "array" + }, + "inboundIds": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "isDisabled": { + "type": "boolean" + }, + "isHidden": { + "type": "boolean" + }, + "keepSniBlank": { + "type": "boolean" + }, + "mihomoIpVersion": { + "enum": [ + "dual", + "ipv4", + "ipv6", + "ipv4-prefer", + "ipv6-prefer" + ], + "type": "string" + }, + "mihomoX25519": { + "type": "boolean" + }, + "muxParams": { + "type": "string" + }, + "nodeGuids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "overrideSniFromAddress": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "pinnedPeerCertSha256": { + "items": { + "type": "string" + }, + "type": "array" + }, + "port": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "remark": { + "maxLength": 256, + "type": "string" + }, + "security": { + "enum": [ + "same", + "tls", + "none", + "reality" + ], + "type": "string" + }, + "serverDescription": { + "maxLength": 64, + "type": "string" + }, + "shuffleHost": { + "type": "boolean" + }, + "sni": { + "type": "string" + }, + "sockoptParams": { + "type": "string" + }, + "sortOrder": { + "type": "integer" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array" + }, + "verifyPeerCertByName": { + "type": "string" + }, + "vlessRoute": { + "type": "string" + } + }, + "required": [ + "allowInsecure", + "alpn", + "echConfigList", + "excludeFromSubTypes", + "finalMask", + "fingerprint", + "groupId", + "hostHeader", + "hosts", + "inboundIds", + "isDisabled", + "isHidden", + "keepSniBlank", + "mihomoIpVersion", + "mihomoX25519", + "muxParams", + "nodeGuids", + "overrideSniFromAddress", + "path", + "pinnedPeerCertSha256", + "port", + "remark", + "security", + "serverDescription", + "shuffleHost", + "sni", + "sockoptParams", + "sortOrder", + "tags", + "verifyPeerCertByName", + "vlessRoute" + ], + "type": "object" + }, "Inbound": { "description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.", "properties": { @@ -1889,17 +1909,12 @@ export const SCHEMAS: Record = { "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" - }, + "obj": {}, "success": { - "description": "Indicates if the operation was successful", "type": "boolean" } }, diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 09606d08a..f17635a56 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -325,6 +325,7 @@ export interface Host { excludeFromSubTypes: string[]; finalMask: string; fingerprint: string; + groupId: string; hostHeader: string; id: number; inboundId: number; @@ -352,6 +353,40 @@ export interface Host { vlessRoute: string; } +export interface HostGroup { + allowInsecure: boolean; + alpn: string[]; + echConfigList: string; + excludeFromSubTypes: string[]; + finalMask: string; + fingerprint: string; + groupId: string; + hostHeader: string; + hosts: string[]; + inboundIds: number[]; + isDisabled: boolean; + isHidden: boolean; + keepSniBlank: boolean; + mihomoIpVersion: string; + mihomoX25519: boolean; + muxParams: string; + nodeGuids: string[]; + overrideSniFromAddress: boolean; + path: string; + pinnedPeerCertSha256: string[]; + port: number; + remark: string; + security: string; + serverDescription: string; + shuffleHost: boolean; + sni: string; + sockoptParams: string; + sortOrder: number; + tags: string[]; + verifyPeerCertByName: string; + vlessRoute: string; +} + export interface Inbound { clientStats: ClientTraffic[]; down: number; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index e57e2e1ad..7f0720c21 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -348,6 +348,7 @@ export const HostSchema = z.object({ excludeFromSubTypes: z.array(z.string()), finalMask: z.string(), fingerprint: z.string(), + groupId: z.string(), hostHeader: z.string(), id: z.number().int(), inboundId: z.number().int(), @@ -376,6 +377,41 @@ export const HostSchema = z.object({ }); export type Host = z.infer; +export const HostGroupSchema = z.object({ + allowInsecure: z.boolean(), + alpn: z.array(z.string()), + echConfigList: z.string(), + excludeFromSubTypes: z.array(z.string()), + finalMask: z.string(), + fingerprint: z.string(), + groupId: z.string(), + hostHeader: z.string(), + hosts: z.array(z.string()), + inboundIds: z.array(z.number().int()), + isDisabled: z.boolean(), + isHidden: z.boolean(), + keepSniBlank: z.boolean(), + mihomoIpVersion: z.enum(['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer']), + mihomoX25519: z.boolean(), + muxParams: z.string(), + nodeGuids: z.array(z.string()), + overrideSniFromAddress: z.boolean(), + path: z.string(), + pinnedPeerCertSha256: z.array(z.string()), + port: z.number().int().min(0).max(65535), + remark: z.string().max(256), + security: z.enum(['same', 'tls', 'none', 'reality']), + serverDescription: z.string().max(64), + shuffleHost: z.boolean(), + sni: z.string(), + sockoptParams: z.string(), + sortOrder: z.number().int(), + tags: z.array(z.string()), + verifyPeerCertByName: z.string(), + vlessRoute: z.string(), +}); +export type HostGroup = z.infer; + export const InboundSchema = z.object({ clientStats: z.array(z.lazy(() => ClientTrafficSchema)), down: z.number().int(), diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index dc9775e1c..c95107cce 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1029,26 +1029,26 @@ export const sections: readonly Section[] = [ method: 'GET', path: '/panel/api/hosts/list', summary: 'List every host across all inbounds, grouped by inbound then ordered by sort order.', - responseSchema: 'Host', + responseSchema: 'HostGroup', responseSchemaArray: true, }, { method: 'GET', - path: '/panel/api/hosts/get/:id', - summary: 'Fetch a single host by ID.', + path: '/panel/api/hosts/get/:groupId', + summary: 'Fetch a single host group by Group ID.', params: [ - { name: 'id', in: 'path', type: 'number', desc: 'Host ID.' }, + { name: 'groupId', in: 'path', type: 'string', desc: 'Host Group ID.' }, ], - responseSchema: 'Host', + responseSchema: 'HostGroup', }, { method: 'GET', path: '/panel/api/hosts/byInbound/:inboundId', - summary: "Fetch one inbound's hosts, ordered by sort order then id.", + summary: "Fetch one inbound's hosts, grouped by host group.", params: [ { name: 'inboundId', in: 'path', type: 'number', desc: 'Inbound ID.' }, ], - responseSchema: 'Host', + responseSchema: 'HostGroup', responseSchemaArray: true, }, { @@ -1060,54 +1060,64 @@ export const sections: readonly Section[] = [ { method: 'POST', path: '/panel/api/hosts/add', - summary: 'Create a host on an inbound. inboundId and remark are required; security defaults to "same" (inherit the inbound).', - body: '{\n "inboundId": 1,\n "remark": "cdn-front",\n "address": "cdn.example.com",\n "port": 8443,\n "security": "same",\n "sni": "",\n "tags": ["CDN"]\n}', + summary: 'Create a host group on inbounds.', + body: '{\n "inboundIds": [1],\n "remark": "cdn-front",\n "hosts": ["cdn.example.com"],\n "port": 8443,\n "security": "same",\n "tags": ["CDN"]\n}', responseSchema: 'Host', + responseSchemaArray: true, }, { method: 'POST', - path: '/panel/api/hosts/update/:id', - summary: 'Replace a host’s content. The inbound and sort order are immutable here (use /reorder for ordering).', + path: '/panel/api/hosts/update/:groupId', + summary: 'Replace a host group’s content.', params: [ - { name: 'id', in: 'path', type: 'number', desc: 'Host ID.' }, + { name: 'groupId', in: 'path', type: 'string', desc: 'Host Group ID.' }, ], - body: '{\n "inboundId": 1,\n "remark": "cdn-front",\n "address": "cdn.example.com",\n "port": 8443,\n "security": "same",\n "sni": "",\n "tags": ["CDN"]\n}', + body: '{\n "inboundIds": [1],\n "remark": "cdn-front",\n "hosts": ["cdn.example.com"],\n "port": 8443,\n "security": "same",\n "tags": ["CDN"]\n}', responseSchema: 'Host', + responseSchemaArray: true, }, { method: 'POST', - path: '/panel/api/hosts/del/:id', - summary: 'Delete a host.', + path: '/panel/api/hosts/del/:groupId', + summary: 'Delete a host group.', params: [ - { name: 'id', in: 'path', type: 'number', desc: 'Host ID.' }, + { name: 'groupId', in: 'path', type: 'string', desc: 'Host Group ID.' }, ], }, { method: 'POST', - path: '/panel/api/hosts/setEnable/:id', - summary: 'Enable or disable a single host (disabled hosts are skipped in subscriptions).', + path: '/panel/api/hosts/setEnable/:groupId', + summary: 'Enable or disable a host group.', params: [ - { name: 'id', in: 'path', type: 'number', desc: 'Host ID.' }, + { name: 'groupId', in: 'path', type: 'string', desc: 'Host Group ID.' }, ], body: '{\n "enable": true\n}', }, { method: 'POST', path: '/panel/api/hosts/reorder', - summary: 'Set host sort order by the position of each id in the array.', - body: '{\n "ids": [3, 1, 2]\n}', + summary: 'Set host group sort order by the position of each groupId in the array.', + body: '{\n "ids": ["abc-123", "def-456"]\n}', + }, + { + method: 'POST', + path: '/panel/api/hosts/bulk/add', + summary: 'Add a host group to inbounds (same as /add).', + body: '{\n "inboundIds": [1, 2],\n "hosts": ["cdn.example.com", "cdn2.example.com:443"],\n "remark": "Cloudflare CDN",\n "port": 0,\n "security": "same",\n "isDisabled": false\n}', + responseSchema: 'Host', + responseSchemaArray: true, }, { method: 'POST', path: '/panel/api/hosts/bulk/setEnable', - summary: 'Enable or disable many hosts in one call.', - body: '{\n "ids": [1, 2, 3],\n "enable": false\n}', + summary: 'Enable or disable many host groups in one call.', + body: '{\n "ids": ["abc-123", "def-456"],\n "enable": false\n}', }, { method: 'POST', path: '/panel/api/hosts/bulk/del', - summary: 'Delete many hosts in one call.', - body: '{\n "ids": [1, 2, 3]\n}', + summary: 'Delete many host groups in one call.', + body: '{\n "ids": ["abc-123", "def-456"]\n}', }, ], }, diff --git a/frontend/src/pages/hosts/HostFormModal.tsx b/frontend/src/pages/hosts/HostFormModal.tsx index 7b0726686..6dedfa087 100644 --- a/frontend/src/pages/hosts/HostFormModal.tsx +++ b/frontend/src/pages/hosts/HostFormModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd'; import { @@ -14,7 +14,7 @@ import { import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import type { HostRecord } from '@/api/queries/useHostsQuery'; -import { HostFormSchema, type HostFormValues } from '@/schemas/api/host'; +import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host'; import type { InboundOption } from '@/schemas/client'; import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives'; import { FormField, rhfZodValidate } from '@/components/form/rhf'; @@ -23,18 +23,17 @@ import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from '@/pages/settings/catTabLabel'; import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms'; -/* - * inboundId is optional in the form so a new host starts unselected (the Select - * shows its placeholder instead of 0); the required rule enforces it on submit. - */ -type FormShape = Omit & { enable: boolean; inboundId?: number }; +type FormShape = Omit & { + enable: boolean; +}; interface HostFormModalProps { open: boolean; mode: 'add' | 'edit'; host: HostRecord | null; inboundOptions: InboundOption[]; - save: (payload: Partial) => Promise<{ success?: boolean; msg?: string } | undefined>; + existingHosts: HostRecord[]; + save: (payload: BulkAddHostValues) => Promise<{ success?: boolean; msg?: string } | undefined>; onOpenChange: (open: boolean) => void; } @@ -42,21 +41,21 @@ const asString = (v: unknown): string => (typeof v === 'string' ? v : ''); function defaultsFor(host: HostRecord | null): FormShape { return { - inboundId: host?.inboundId, + inboundIds: host?.inboundIds ?? [], + hosts: (host?.hosts || []).filter((h) => h && h.trim() !== ''), sortOrder: host?.sortOrder ?? 0, remark: host?.remark ?? '', serverDescription: host?.serverDescription ?? '', enable: host ? !host.isDisabled : true, isHidden: host?.isHidden ?? false, tags: host?.tags ?? [], - address: host?.address ?? '', port: host?.port ?? 0, - security: (host?.security as HostFormValues['security']) ?? 'same', + security: (host?.security as BulkAddHostValues['security']) ?? 'same', sni: host?.sni ?? '', hostHeader: host?.hostHeader ?? '', path: host?.path ?? '', - alpn: (host?.alpn as HostFormValues['alpn']) ?? [], - fingerprint: host?.fingerprint as HostFormValues['fingerprint'], + alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [], + fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'], overrideSniFromAddress: host?.overrideSniFromAddress ?? false, keepSniBlank: host?.keepSniBlank ?? false, pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [], @@ -67,31 +66,30 @@ function defaultsFor(host: HostRecord | null): FormShape { sockoptParams: asString(host?.sockoptParams), finalMask: host?.finalMask ?? '', vlessRoute: host?.vlessRoute ?? '', - excludeFromSubTypes: (host?.excludeFromSubTypes as HostFormValues['excludeFromSubTypes']) ?? [], + excludeFromSubTypes: (host?.excludeFromSubTypes as BulkAddHostValues['excludeFromSubTypes']) ?? [], nodeGuids: host?.nodeGuids ?? [], - mihomoIpVersion: host?.mihomoIpVersion as HostFormValues['mihomoIpVersion'], + mihomoIpVersion: host?.mihomoIpVersion as BulkAddHostValues['mihomoIpVersion'], mihomoX25519: host?.mihomoX25519 ?? false, shuffleHost: host?.shuffleHost ?? false, }; } -export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) { +export default function HostFormModal({ open, mode, host, inboundOptions, existingHosts, save, onOpenChange }: HostFormModalProps) { const { t } = useTranslation(); const { isMobile } = useMediaQuery(); const methods = useForm({ defaultValues: defaultsFor(host) }); + const [messageApi, messageContextHolder] = message.useMessage(); + const [loading, setLoading] = useState(false); - /* - * Drive conditional field visibility off the selected security, like the - * legacy externalProxy form: same/none inherit fully and hide every TLS/cert - * field; reality shows only the reality-relevant subset (its keys are - * inherited from the inbound); tls shows the full TLS override set. - */ const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string; const showTls = security === 'tls' || security === 'reality'; const showTlsExtras = security === 'tls'; useEffect(() => { - if (open) methods.reset(defaultsFor(host)); + if (open) { + methods.reset(defaultsFor(host)); + setLoading(false); + } }, [open, host, methods]); const { nodes } = useNodesQuery(); @@ -114,15 +112,42 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save, const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []); const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []); + const hostOptions = useMemo(() => { + const addresses = new Set(); + for (const h of existingHosts || []) { + if (h.hosts) { + for (const addr of h.hosts) { + if (addr && addr.trim() !== '') { + addresses.add(addr); + } + } + } + } + return Array.from(addresses).map((addr) => ({ value: addr, label: addr })); + }, [existingHosts]); + const onFinish = async (values: FormShape) => { + if (loading) return; const { enable, ...rest } = values; - const payload: Partial = { ...rest, isDisabled: !enable }; - const res = await save(payload); - if (res?.success) { - message.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update')); - onOpenChange(false); - } else if (res?.msg) { - message.error(res.msg); + const isDisabled = !enable; + const payload: BulkAddHostValues = { + ...rest, + hosts: (rest.hosts || []).filter((h) => h && h.trim() !== ''), + isDisabled, + }; + setLoading(true); + try { + const res = await save(payload); + if (res?.success) { + messageApi.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update')); + onOpenChange(false); + } else if (res?.msg) { + messageApi.error(res.msg); + } + } catch (err) { + console.error(err); + } finally { + setLoading(false); } }; @@ -132,12 +157,14 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save, title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')} onOk={methods.handleSubmit(onFinish)} onCancel={() => onOpenChange(false)} + confirmLoading={loading} okText={t('save')} cancelText={t('cancel')} destroyOnHidden width={isMobile ? '95vw' : 760} styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }} > + {messageContextHolder}
, t('pages.hosts.sections.basic'), isMobile), children: ( <> - + - + + +