From 42690e1b8c69ac64e3919dd2cf42ead777d63c5f Mon Sep 17 00:00:00 2001 From: AmirRnz <155143890+AmirRnz@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:05:20 +0330 Subject: [PATCH] feat(hosts): bulk-add multiple hosts to multiple inbounds (#5677) * feat(hosts): bulk-add multiple hosts to multiple inbounds Allow users to select multiple inbound IDs and enter multiple host addresses (with optional per-host port override) in a single form submission. - Add BulkAddHostReq entity and POST /panel/api/hosts/bulk/add endpoint - Add AddHostsBulk service with GORM transaction safety - Add parseHostAndPort helper (IPv4, bracketed/bracketless IPv6, port) - Update HostFormModal to multi-select inbounds and tag-input hosts - Wire bulkCreate mutation in HostsPage with existing-host suggestions - Register endpoint in api-docs/endpoints.ts and regenerate OpenAPI/Zod * feat(hosts): group override records by group_id and support group editing * fix: import Popover in HostList * fix: use messageApi in HostFormModal * fix(hosts): resolve 4 bugs found in host-group code review - fix(schema): allow empty hosts array in BulkAddHostSchema so users can save a host without an address (inherits inbound endpoint). The old .min(1) was never enforced at runtime since the schema is only used for type inference, but the type was incorrect. - fix(service): validate new inbound IDs in UpdateHostGroup before deleting old rows, matching the same check already present in AddHostGroup. Prevents orphaned host rows when an invalid inbound ID is supplied on edit. - fix(service): replace full-table scan in GetHostsByInbound with two targeted queries (DISTINCT group_id WHERE inbound_id=?, then WHERE group_id IN ?) to avoid loading every host in the DB. - fix(mutations): remove unused createMut / create export from useHostMutations. The /hosts/add endpoint is identical to /hosts/bulk/add; only bulkCreate is used by the UI. * fix(hosts): address code review feedback (optimize bulk inserts, add validation tests, and remove comments) * fix(fmt): apply gofumpt formatting to model.go and db.go The previous merge commit incorrectly applied gofmt (tab-aligned) to these files. The repository's golangci config requires gofumpt+goimports which produces space-aligned struct fields. This commit restores the correct gofumpt formatting that matches upstream/main. * chore(frontend): regenerate API schemas and update lockfile * fix * refactor(hosts): dedupe host-group service and tidy frontend AddHostGroup and UpdateHostGroup shared an identical ~35-field model.Host construction and hand-rolled transaction boilerplate (tx.Begin plus a committed flag plus a deferred recover/rollback). Extract buildHostRows, validateInboundsExist and formatHostAddr, and run every mutation through db.Transaction. groupHosts collapses its duplicated address/port formatting and create/append fork into one path using slices.Contains. Behavior-preserving: host.go drops ~90 lines with the existing service/controller tests green. Frontend: drop the Partial union and two as-casts in HostsPage.onSave (the modal always passes a full BulkAddHostValues), and remove the movable index map in HostList in favor of the table render index arg. --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Sanaei --- frontend/public/openapi.json | 802 +++++++++++-------- frontend/src/api/queries/useHostMutations.ts | 38 +- frontend/src/generated/examples.ts | 48 ++ frontend/src/generated/schemas.ts | 333 ++++---- frontend/src/generated/types.ts | 35 + frontend/src/generated/zod.ts | 36 + frontend/src/pages/api-docs/endpoints.ts | 60 +- frontend/src/pages/hosts/HostFormModal.tsx | 102 ++- frontend/src/pages/hosts/HostList.tsx | 129 ++- frontend/src/pages/hosts/HostsPage.tsx | 69 +- frontend/src/schemas/api/host.ts | 30 +- internal/database/db.go | 137 +--- internal/database/host_test.go | 3 +- internal/database/model/model.go | 5 +- internal/web/controller/host.go | 65 +- internal/web/controller/host_test.go | 88 +- internal/web/entity/entity.go | 220 ++--- internal/web/service/host.go | 388 +++++++-- internal/web/service/host_test.go | 298 +++++-- internal/web/translation/ar-EG.json | 5 +- internal/web/translation/en-US.json | 11 +- internal/web/translation/es-ES.json | 5 +- internal/web/translation/fa-IR.json | 5 +- internal/web/translation/id-ID.json | 3 +- internal/web/translation/ja-JP.json | 3 +- internal/web/translation/pt-BR.json | 5 +- internal/web/translation/ru-RU.json | 5 +- internal/web/translation/tr-TR.json | 5 +- internal/web/translation/uk-UA.json | 5 +- internal/web/translation/vi-VN.json | 5 +- internal/web/translation/zh-CN.json | 3 +- internal/web/translation/zh-TW.json | 3 +- tools/openapigen/main.go | 1 + 33 files changed, 1842 insertions(+), 1108 deletions(-) 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: ( <> - + - + + +