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.
This commit is contained in:
MHSanaei
2026-05-28 12:59:20 +02:00
parent bf1b488a63
commit 530e338c66
20 changed files with 764 additions and 174 deletions
+1
View File
@@ -67,6 +67,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
clients := api.Group("/clients")
NewClientController(clients)
NewGroupController(clients)
// Server API
server := api.Group("/server")
+2
View File
@@ -89,6 +89,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
basePath = "/panel/api/inbounds"
case "client.go":
basePath = "/panel/api/clients"
case "group.go":
basePath = "/panel/api/clients"
case "server.go":
basePath = "/panel/api/server"
case "node.go":
+1 -107
View File
@@ -47,22 +47,15 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.POST("/bulkAdjust", a.bulkAdjust)
g.POST("/bulkDel", a.bulkDelete)
g.POST("/bulkCreate", a.bulkCreate)
g.POST("/bulkAssignGroup", a.bulkAssignGroup)
g.POST("/bulkAttach", a.bulkAttach)
g.POST("/bulkDetach", a.bulkDetach)
g.POST("/bulkResetTraffic", a.bulkResetTraffic)
g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
g.POST("/ips/:email", a.getIps)
g.POST("/clearIps/:email", a.clearIps)
g.POST("/onlines", a.onlines)
g.POST("/lastOnline", a.lastOnline)
g.GET("/groups", a.listGroups)
g.GET("/groups/:name/emails", a.groupEmails)
g.POST("/groups/create", a.createGroup)
g.POST("/groups/rename", a.renameGroup)
g.POST("/groups/delete", a.deleteGroup)
g.POST("/bulkResetTraffic", a.bulkResetTraffic)
}
func (a *ClientController) list(c *gin.Context) {
@@ -220,27 +213,6 @@ type bulkDeleteRequest struct {
KeepTraffic bool `json:"keepTraffic"`
}
type bulkAssignGroupRequest struct {
Emails []string `json:"emails"`
Group string `json:"group"`
}
func (a *ClientController) bulkAssignGroup(c *gin.Context) {
var req bulkAssignGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
affected, err := a.clientService.AssignGroup(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 bulkAttachRequest struct {
Emails []string `json:"emails"`
InboundIds []int `json:"inboundIds"`
@@ -471,25 +443,6 @@ func (a *ClientController) detach(c *gin.Context) {
notifyClientsChanged()
}
func (a *ClientController) listGroups(c *gin.Context) {
rows, err := a.clientService.ListGroups()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, rows, nil)
}
func (a *ClientController) groupEmails(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 bulkResetRequest struct {
Emails []string `json:"emails"`
}
@@ -509,62 +462,3 @@ func (a *ClientController) bulkResetTraffic(c *gin.Context) {
a.xrayService.SetToNeedRestart()
notifyClientsChanged()
}
type groupCreateBody struct {
Name string `json:"name"`
}
func (a *ClientController) createGroup(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 *ClientController) renameGroup(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 *ClientController) deleteGroup(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()
}
+154
View File
@@ -0,0 +1,154 @@
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()
}
+5 -1
View File
@@ -1402,7 +1402,11 @@ func (s *ClientService) DeleteGroup(name string) (int, error) {
return s.replaceGroupValue(name, "")
}
func (s *ClientService) AssignGroup(emails []string, group string) (int, error) {
func (s *ClientService) RemoveFromGroup(emails []string) (int, error) {
return s.AddToGroup(emails, "")
}
func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
group = strings.TrimSpace(group)
if len(emails) == 0 {
return 0, nil
+22 -8
View File
@@ -8,6 +8,8 @@
"save": "Save",
"logout": "Log Out",
"create": "Create",
"add": "Add",
"remove": "Remove",
"update": "Update",
"copy": "Copy",
"copied": "Copied",
@@ -299,7 +301,7 @@
"delAllClientsConfirmTitle": "Delete all {count} clients from \"{remark}\"?",
"delAllClientsConfirmContent": "This removes every client from this inbound and drops their traffic records. The inbound itself is kept. This cannot be undone.",
"attachClients": "Attach Clients To…",
"assignClientsGroup": "Assign Clients To Group…",
"addClientsToGroup": "Add Clients To Group…",
"attachClientsTitle": "Attach clients from \"{remark}\"",
"attachClientsDesc": "Attaches the same {count} clients (same UUID/password and shared traffic) to the selected inbound(s). They stay on this inbound too.",
"attachClientsTargets": "Target inbounds",
@@ -536,12 +538,15 @@
"deleteSelected": "Delete ({count})",
"adjustSelected": "Adjust ({count})",
"subLinksSelected": "Sub links ({count})",
"assignGroupSelected": "Group ({count})",
"assignGroupTitle": "Assign group to {count} client(s)",
"assignGroupTooltip": "Pick an existing group or type a new name. Leave blank to clear the group on the selected clients.",
"assignGroupPlaceholder": "Group name (leave blank to clear)",
"assignGroupAssignedToast": "Assigned {count} client(s) to {group}",
"assignGroupClearedToast": "Cleared group from {count} client(s)",
"addToGroupTitle": "Add {count} client(s) to a group",
"addToGroupTooltip": "Pick an existing group or type a new name. Use the Ungroup action to remove clients from their current group.",
"addToGroupPlaceholder": "Group name",
"addToGroupSuccessToast": "Added {count} client(s) to {group}",
"ungroupSuccessToast": "Cleared group from {count} client(s)",
"ungroup": "Ungroup",
"ungroupConfirmTitle": "Remove {count} client(s) from their group?",
"ungroupConfirmContent": "Clears the group label on each selected client. Clients themselves are kept (use Delete to remove them entirely).",
"addToGroup": "Add to group",
"attach": "Attach",
"adjust": "Adjust",
"subLinks": "Sub links",
@@ -629,7 +634,16 @@
"deleteClientsConfirmTitle": "Delete all clients in {name}?",
"deleteClientsConfirmContent": "This permanently removes {count} client(s) along with their traffic records. The group label is cleared too. This cannot be undone.",
"deleteClientsSuccess": "Deleted {count} client(s).",
"deleteClientsMixed": "{ok} deleted, {failed} skipped"
"deleteClientsMixed": "{ok} deleted, {failed} skipped",
"addToGroup": "Add clients…",
"addToGroupTitle": "Add clients to group \"{name}\"",
"addToGroupDesc": "Select clients to add to this group. They keep their existing inbound attachments; only the group label changes. Clients already in this group are not listed.",
"addToGroupEmpty": "No other clients available to add.",
"addToGroupResult": "Added {count} client(s) to {name}.",
"removeFromGroup": "Remove clients…",
"removeFromGroupTitle": "Remove clients from group \"{name}\"",
"removeFromGroupDesc": "Select members to remove from this group. Clients themselves are kept (use \"Delete clients in group\" to remove them entirely).",
"removeFromGroupResult": "Removed {count} client(s) from {name}."
},
"nodes": {
"title": "Nodes",
+7 -1
View File
@@ -8,6 +8,8 @@
"save": "ذخیره",
"logout": "خروج",
"create": "ایجاد",
"add": "افزودن",
"remove": "حذف",
"update": "به‌روزرسانی",
"copy": "کپی",
"copied": "کپی شد",
@@ -294,7 +296,7 @@
"delAllClientsConfirmTitle": "حذف هر {count} کلاینت اینباند «{remark}»؟",
"delAllClientsConfirmContent": "تمام کلاینت‌های این اینباند به همراه رکوردهای ترافیک‌شان حذف می‌شوند. خود اینباند باقی می‌ماند. این عمل غیرقابل بازگشت است.",
"attachClients": "اتصال کلاینت‌ها به…",
"assignClientsGroup": "افزودن کلاینت‌ها به گروه…",
"addClientsToGroup": "افزودن کلاینت‌ها به گروه…",
"attachClientsTitle": "اتصال کلاینت‌های «{remark}»",
"attachClientsDesc": "همان {count} کلاینت (با همان UUID/پسورد و ترافیک مشترک) را به اینباند(های) انتخاب‌شده هم متصل می‌کند. روی این اینباند هم باقی می‌مانند.",
"attachClientsTargets": "اینباندهای مقصد",
@@ -517,6 +519,10 @@
"adjust": "تنظیم",
"subLinks": "لینک‌های ساب",
"selectedCount": "{count} انتخاب‌شده",
"ungroup": "حذف گروه",
"ungroupConfirmTitle": "{count} کلاینت از گروهشان حذف شود؟",
"ungroupConfirmContent": "برچسب گروه از هر کلاینت انتخاب‌شده پاک می‌شود. خود کلاینت‌ها حفظ می‌شوند (برای حذف کامل، از Delete استفاده کنید).",
"addToGroup": "افزودن به گروه",
"attachSelected": "اتصال ({count})",
"attachToInboundsTitle": "اتصال {count} کلاینت به اینباند(ها)",
"attachToInboundsDesc": "{count} کلاینت انتخاب‌شده (با همان UUID/پسورد و ترافیک مشترک) به اینباند(های) انتخابی متصل می‌شوند. روی اینباندهای فعلی هم باقی می‌مانند.",