Files
3x-ui/web/controller/group.go
MHSanaei 530e338c66 refactor(clients): coherent group management — rename, split, extract
This bundles a set of group-related improvements that built up across
one session and only make sense together.

Terminology / API surface:
- Rename "assign group" → "add to group" everywhere: i18n keys,
  callback names (bulkAddToGroup), component + file names
  (BulkAddToGroupModal, AddClientsToGroupModal), Go controller/struct
  names (bulkAddToGroup, AddToGroup), OpenAPI summaries. Nothing keeps
  the word "assign" anymore.
- Move group routes under /panel/api/clients/groups/* (was
  /bulkAssignGroup at the clients root).
- Split add and remove into two endpoints: /groups/bulkAdd now rejects
  empty group; new /groups/bulkRemove clears the label for the given
  emails. The old "submit empty to clear" UX is gone — Ungroup is its
  own action.

UI affordances on Clients page:
- Promote Group + Ungroup to visible bar buttons next to Attach +
  Detach. Group reuses BulkAddToGroupModal; Ungroup pops a danger
  confirm and calls bulkRemoveFromGroup.
- Custom UngroupIcon (TagsOutlined with a diagonal strike) for the
  Ungroup button so the pairing reads at a glance.
- Hide the Group column when no clients have a group label yet —
  removes a column of em-dashes on fresh installs.

UI on Groups page:
- New per-row Add clients… / Remove clients… actions backed by
  GroupAddClientsModal and GroupRemoveClientsModal: rich client picker
  (email / comment / current group / enable) with search and
  preserveSelectedRowKeys, mirroring the inbounds Attach modal UX.

Controller split:
- Move all /groups/* routes, handlers, and request bodies out of
  web/controller/client.go into a dedicated web/controller/group.go
  (GroupController with leaner clientService + xrayService
  dependencies). URLs are byte-identical because the new controller
  registers on the same parent gin.RouterGroup; api_docs_test.go gets
  a group.go → /panel/api/clients basePath entry so its route
  extraction keeps working.

Invalidation dedup:
- Removing a client from a group on the Groups page used to refetch
  /clients/groups and /clients/onlines three times: once from the
  mutation's onSuccess, once from a redundant invalidate() in the
  page's onSubmit, once from the WebSocket invalidate broadcast that
  the backend fires after every mutation. The manual invalidate() is
  gone, and a small invalidationTracker module lets websocketBridge
  skip WS-driven invalidates that arrive within 1.5s of a local
  invalidate — bringing the refetch count down to one. The WS path
  still works for changes made by another tab or user.
2026-05-28 12:59:20 +02:00

155 lines
3.8 KiB
Go

package controller
import (
"strings"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/gin-gonic/gin"
)
type GroupController struct {
clientService service.ClientService
xrayService service.XrayService
}
func NewGroupController(g *gin.RouterGroup) *GroupController {
a := &GroupController{}
a.initRouter(g)
return a
}
func (a *GroupController) initRouter(g *gin.RouterGroup) {
g.GET("/groups", a.list)
g.GET("/groups/:name/emails", a.emails)
g.POST("/groups/create", a.create)
g.POST("/groups/rename", a.rename)
g.POST("/groups/delete", a.delete)
g.POST("/groups/bulkAdd", a.bulkAdd)
g.POST("/groups/bulkRemove", a.bulkRemove)
}
func (a *GroupController) list(c *gin.Context) {
rows, err := a.clientService.ListGroups()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, rows, nil)
}
func (a *GroupController) emails(c *gin.Context) {
name := c.Param("name")
emails, err := a.clientService.EmailsByGroup(name)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, emails, nil)
}
type groupCreateBody struct {
Name string `json:"name"`
}
func (a *GroupController) create(c *gin.Context) {
var body groupCreateBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if err := a.clientService.CreateGroup(body.Name); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"name": body.Name}, nil)
notifyClientsChanged()
}
type groupRenameBody struct {
OldName string `json:"oldName"`
NewName string `json:"newName"`
}
func (a *GroupController) rename(c *gin.Context) {
var body groupRenameBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
affected, err := a.clientService.RenameGroup(body.OldName, body.NewName)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
a.xrayService.SetToNeedRestart()
jsonObj(c, gin.H{"affected": affected}, nil)
notifyClientsChanged()
}
type groupDeleteBody struct {
Name string `json:"name"`
}
func (a *GroupController) delete(c *gin.Context) {
var body groupDeleteBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
affected, err := a.clientService.DeleteGroup(body.Name)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
a.xrayService.SetToNeedRestart()
jsonObj(c, gin.H{"affected": affected}, nil)
notifyClientsChanged()
}
type bulkAddToGroupRequest struct {
Emails []string `json:"emails"`
Group string `json:"group"`
}
func (a *GroupController) bulkAdd(c *gin.Context) {
var req bulkAddToGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if strings.TrimSpace(req.Group) == "" {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("group name is required"))
return
}
affected, err := a.clientService.AddToGroup(req.Emails, req.Group)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"affected": affected}, nil)
a.xrayService.SetToNeedRestart()
notifyClientsChanged()
}
type bulkRemoveFromGroupRequest struct {
Emails []string `json:"emails"`
}
func (a *GroupController) bulkRemove(c *gin.Context) {
var req bulkRemoveFromGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
affected, err := a.clientService.RemoveFromGroup(req.Emails)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"affected": affected}, nil)
a.xrayService.SetToNeedRestart()
notifyClientsChanged()
}