mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-15 00:56:08 +00:00
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 <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -3,15 +3,13 @@ package controller
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HostController exposes CRUD + ordering for Host override endpoints under
|
||||
// /panel/api/hosts. Thin HTTP layer over HostService; mirrors NodeController.
|
||||
type HostController struct {
|
||||
hostService service.HostService
|
||||
}
|
||||
@@ -24,15 +22,16 @@ func NewHostController(g *gin.RouterGroup) *HostController {
|
||||
|
||||
func (a *HostController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/get/:id", a.get)
|
||||
g.GET("/get/:groupId", a.get)
|
||||
g.GET("/byInbound/:inboundId", a.byInbound)
|
||||
g.GET("/tags", a.tags)
|
||||
|
||||
g.POST("/add", a.add)
|
||||
g.POST("/update/:id", a.update)
|
||||
g.POST("/del/:id", a.del)
|
||||
g.POST("/setEnable/:id", a.setEnable)
|
||||
g.POST("/update/:groupId", a.update)
|
||||
g.POST("/del/:groupId", a.del)
|
||||
g.POST("/setEnable/:groupId", a.setEnable)
|
||||
g.POST("/reorder", a.reorder)
|
||||
g.POST("/bulk/add", a.add)
|
||||
g.POST("/bulk/setEnable", a.bulkSetEnable)
|
||||
g.POST("/bulk/del", a.bulkDel)
|
||||
}
|
||||
@@ -47,12 +46,8 @@ func (a *HostController) list(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *HostController) get(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
h, err := a.hostService.GetHost(id)
|
||||
groupId := c.Param("groupId")
|
||||
h, err := a.hostService.GetHostGroup(groupId)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.obtain"), err)
|
||||
return
|
||||
@@ -84,11 +79,11 @@ func (a *HostController) tags(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *HostController) add(c *gin.Context) {
|
||||
h, ok := middleware.BindAndValidate[model.Host](c)
|
||||
req, ok := middleware.BindJSONAndValidate[entity.HostGroup](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
created, err := a.hostService.AddHost(h)
|
||||
created, err := a.hostService.AddHostGroup(req)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.add"), err)
|
||||
return
|
||||
@@ -97,16 +92,12 @@ func (a *HostController) add(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *HostController) update(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
h, ok := middleware.BindAndValidate[model.Host](c)
|
||||
groupId := c.Param("groupId")
|
||||
req, ok := middleware.BindJSONAndValidate[entity.HostGroup](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
updated, err := a.hostService.UpdateHost(id, h)
|
||||
updated, err := a.hostService.UpdateHostGroup(groupId, req)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
@@ -115,12 +106,8 @@ func (a *HostController) update(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *HostController) del(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.DeleteHost(id); err != nil {
|
||||
groupId := c.Param("groupId")
|
||||
if err := a.hostService.DeleteHostGroup(groupId); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
|
||||
return
|
||||
}
|
||||
@@ -128,11 +115,7 @@ func (a *HostController) del(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *HostController) setEnable(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
groupId := c.Param("groupId")
|
||||
body := struct {
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
}{}
|
||||
@@ -140,7 +123,7 @@ func (a *HostController) setEnable(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.SetHostEnable(id, body.Enable); err != nil {
|
||||
if err := a.hostService.SetHostGroupEnable(groupId, body.Enable); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
@@ -149,13 +132,13 @@ func (a *HostController) setEnable(c *gin.Context) {
|
||||
|
||||
func (a *HostController) reorder(c *gin.Context) {
|
||||
var req struct {
|
||||
Ids []int `json:"ids" form:"ids"`
|
||||
Ids []string `json:"ids" form:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.ReorderHosts(req.Ids); err != nil {
|
||||
if err := a.hostService.ReorderHostGroups(req.Ids); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
@@ -164,14 +147,14 @@ func (a *HostController) reorder(c *gin.Context) {
|
||||
|
||||
func (a *HostController) bulkSetEnable(c *gin.Context) {
|
||||
var req struct {
|
||||
Ids []int `json:"ids" form:"ids"`
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
Ids []string `json:"ids" form:"ids"`
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.SetHostsEnable(req.Ids, req.Enable); err != nil {
|
||||
if err := a.hostService.SetHostsGroupEnable(req.Ids, req.Enable); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
@@ -180,13 +163,13 @@ func (a *HostController) bulkSetEnable(c *gin.Context) {
|
||||
|
||||
func (a *HostController) bulkDel(c *gin.Context) {
|
||||
var req struct {
|
||||
Ids []int `json:"ids" form:"ids"`
|
||||
Ids []string `json:"ids" form:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.DeleteHosts(req.Ids); err != nil {
|
||||
if err := a.hostService.DeleteHostsGroup(req.Ids); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
@@ -17,12 +16,11 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
)
|
||||
|
||||
func newHostTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
// I18nWeb logs a warning when the localizer is absent (as in tests); the
|
||||
// logger must be initialised so that warning does not nil-panic.
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
gin.SetMode(gin.TestMode)
|
||||
dbDir := t.TempDir()
|
||||
@@ -62,8 +60,6 @@ func doHostReq(t *testing.T, engine *gin.Engine, method, path string, body any)
|
||||
return env
|
||||
}
|
||||
|
||||
// TestHostController_AddListGetDelete exercises the CRUD round-trip and asserts
|
||||
// the {success,msg,obj} envelope convention through the registered routes.
|
||||
func TestHostController_AddListGetDelete(t *testing.T) {
|
||||
newHostTestDB(t)
|
||||
engine := gin.New()
|
||||
@@ -74,53 +70,83 @@ func TestHostController_AddListGetDelete(t *testing.T) {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
|
||||
// add
|
||||
add := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/add", map[string]any{
|
||||
"inboundId": ib.Id, "remark": "h1", "address": "h1.example.com", "port": 8443,
|
||||
"inboundIds": []int{ib.Id}, "remark": "h1", "hosts": []string{"h1.example.com"}, "port": 8443,
|
||||
})
|
||||
if !add.Success {
|
||||
t.Fatalf("add not successful: %s", add.Msg)
|
||||
}
|
||||
var created model.Host
|
||||
var created []*model.Host
|
||||
if err := json.Unmarshal(add.Obj, &created); err != nil {
|
||||
t.Fatalf("decode created host: %v", err)
|
||||
t.Fatalf("decode created hosts: %v", err)
|
||||
}
|
||||
if created.Id == 0 || created.Remark != "h1" {
|
||||
t.Fatalf("created host = %+v", created)
|
||||
if len(created) != 1 || created[0].GroupId == "" || created[0].Remark != "h1" {
|
||||
t.Fatalf("created hosts = %+v", created)
|
||||
}
|
||||
groupId := created[0].GroupId
|
||||
|
||||
// list
|
||||
list := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/list", nil)
|
||||
var hosts []model.Host
|
||||
if err := json.Unmarshal(list.Obj, &hosts); err != nil {
|
||||
var groups []entity.HostGroup
|
||||
if err := json.Unmarshal(list.Obj, &groups); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if len(hosts) != 1 || hosts[0].Id != created.Id {
|
||||
t.Fatalf("list = %+v, want one host id=%d", hosts, created.Id)
|
||||
if len(groups) != 1 || groups[0].GroupId != groupId {
|
||||
t.Fatalf("list = %+v, want one group groupId=%s", groups, groupId)
|
||||
}
|
||||
|
||||
// get
|
||||
get := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+itoa(created.Id), nil)
|
||||
get := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+groupId, nil)
|
||||
if !get.Success {
|
||||
t.Fatalf("get not successful: %s", get.Msg)
|
||||
}
|
||||
|
||||
// del
|
||||
del := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/del/"+itoa(created.Id), nil)
|
||||
if !del.Success {
|
||||
t.Fatalf("del not successful: %s", del.Msg)
|
||||
update := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/update/"+groupId, map[string]any{
|
||||
"inboundIds": []int{ib.Id}, "remark": "h1-updated", "hosts": []string{"h1.example.com"}, "port": 8443,
|
||||
})
|
||||
if !update.Success {
|
||||
t.Fatalf("update not successful: %s", update.Msg)
|
||||
}
|
||||
get2 := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+groupId, nil)
|
||||
var group2 entity.HostGroup
|
||||
_ = json.Unmarshal(get2.Obj, &group2)
|
||||
if group2.Remark != "h1-updated" {
|
||||
t.Fatalf("update did not change remark: %s", group2.Remark)
|
||||
}
|
||||
|
||||
setEn := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/bulk/setEnable", map[string]any{
|
||||
"ids": []string{groupId}, "enable": false,
|
||||
})
|
||||
if !setEn.Success {
|
||||
t.Fatalf("bulk/setEnable not successful: %s", setEn.Msg)
|
||||
}
|
||||
get3 := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+groupId, nil)
|
||||
var group3 entity.HostGroup
|
||||
_ = json.Unmarshal(get3.Obj, &group3)
|
||||
if !group3.IsDisabled {
|
||||
t.Fatalf("bulk/setEnable did not disable host group")
|
||||
}
|
||||
|
||||
add2 := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/bulk/add", map[string]any{
|
||||
"inboundIds": []int{ib.Id}, "remark": "h2", "hosts": []string{"h2.example.com"}, "port": 8443,
|
||||
})
|
||||
var created2 []*model.Host
|
||||
_ = json.Unmarshal(add2.Obj, &created2)
|
||||
groupId2 := created2[0].GroupId
|
||||
|
||||
bulkDel := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/bulk/del", map[string]any{
|
||||
"ids": []string{groupId, groupId2},
|
||||
})
|
||||
if !bulkDel.Success {
|
||||
t.Fatalf("bulk/del not successful: %s", bulkDel.Msg)
|
||||
}
|
||||
|
||||
list2 := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/list", nil)
|
||||
var hosts2 []model.Host
|
||||
_ = json.Unmarshal(list2.Obj, &hosts2)
|
||||
if len(hosts2) != 0 {
|
||||
t.Fatalf("after delete, list = %+v, want empty", hosts2)
|
||||
var groups2 []entity.HostGroup
|
||||
_ = json.Unmarshal(list2.Obj, &groups2)
|
||||
if len(groups2) != 0 {
|
||||
t.Fatalf("after delete, list = %+v, want empty", groups2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostController_AuthInherited mirrors production wiring: the hosts group is
|
||||
// nested under the api group guarded by checkAPIAuth, so an unauthenticated XHR
|
||||
// to a hosts route is rejected (401) — the auth is inherited, not re-declared.
|
||||
func TestHostController_AuthInherited(t *testing.T) {
|
||||
newHostTestDB(t)
|
||||
engine := gin.New()
|
||||
@@ -140,7 +166,3 @@ func TestHostController_AuthInherited(t *testing.T) {
|
||||
t.Fatalf("unauthenticated hosts/list = %d, want 401 (auth inherited)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
return strconv.Itoa(i)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user